Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/Http/Controllers/Auth/LinkedInController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ public function callback(Request $request): View
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
'scopes' => $socialUser->approvedScopes ?? null,
// LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here.
'scopes' => explode(',', implode(',', $socialUser->approvedScopes)),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Controllers/Auth/PinterestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ public function callback(Request $request): View
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30),
'scopes' => $socialUser->approvedScopes ?? $this->scopes,
// Pinterest returns scopes space-joined but Socialite doesn't split them, so re-split here.
'scopes' => explode(' ', implode(' ', $socialUser->approvedScopes)),
'status' => Status::Connected,
]);

Expand Down
56 changes: 56 additions & 0 deletions tests/Feature/Services/Social/LinkedInTokenSynchronizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,59 @@
// Should NOT have been synced because it's in a different workspace
expect($pageAccountOtherWorkspace->access_token)->toBe('old-access-token');
});

test('does not sync across different LinkedIn admins inside the same workspace', function () {
// Scenario: multi-user workspace. Owner connects their own LinkedIn
// (personal + page they admin); a teammate later connects their
// company's LinkedIn Page that they admin. Two distinct LinkedIn
// user identities coexist in the same workspace and must NOT share
// tokens — each token belongs to whoever authorized it at LinkedIn.

$ownerLinkedInId = 'linkedin-user-owner';
$clientLinkedInId = 'linkedin-user-client';

$ownerPersonal = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => $ownerLinkedInId,
'access_token' => 'owner-new-access-token',
'refresh_token' => 'owner-new-refresh-token',
'token_expires_at' => now()->addDays(60),
]);

$ownerPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-owner',
'access_token' => 'owner-old-access-token',
'refresh_token' => 'owner-old-refresh-token',
'meta' => [
'organization_id' => 'org-owner',
'admin_user_id' => $ownerLinkedInId,
'admin_name' => 'Owner',
],
]);

$clientPage = SocialAccount::factory()->linkedinPage()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'org-client',
'access_token' => 'client-untouched-access-token',
'refresh_token' => 'client-untouched-refresh-token',
'meta' => [
'organization_id' => 'org-client',
'admin_user_id' => $clientLinkedInId,
'admin_name' => 'Client',
],
]);

$this->synchronizer->syncTokens($ownerPersonal);

// Owner's own page got the new token.
$ownerPage->refresh();
expect($ownerPage->access_token)->toBe('owner-new-access-token');
expect($ownerPage->refresh_token)->toBe('owner-new-refresh-token');

// Client's page (different admin_user_id) was NOT touched even
// though it lives in the same workspace.
$clientPage->refresh();
expect($clientPage->access_token)->toBe('client-untouched-access-token');
expect($clientPage->refresh_token)->toBe('client-untouched-refresh-token');
});
36 changes: 36 additions & 0 deletions tests/Feature/Social/LinkedInControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@
]);
});

test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () {
session(['social_connect_workspace' => $this->workspace->id]);

$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('abc123xyz');
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
// LinkedIn returns scopes comma-separated; Socialite's LinkedInProvider
// splits on space (the OAuth 2.0 default), so approvedScopes lands as
// a single-element array with the whole CSV inside. The save path
// must normalize back to individual tokens.
$socialiteUser->approvedScopes = ['email,openid,profile,r_basicprofile,w_member_social'];

Socialite::shouldReceive('driver')
->with('linkedin')
->andReturn(Mockery::mock(['user' => $socialiteUser]));

Http::fake([
'https://api.linkedin.com/v2/me*' => Http::response([
'id' => 'abc123xyz',
'vanityName' => 'johndoe',
], 200),
]);

$this->actingAs($this->user)->get(route('app.social.linkedin.callback'));

$account = SocialAccount::where('platform_user_id', 'abc123xyz')->first();
expect($account->scopes)->toEqualCanonicalizing([
'email', 'openid', 'profile', 'r_basicprofile', 'w_member_social',
]);
});

test('linkedin callback fails with expired session', function () {
// No session data - simulating expired session

Expand Down
28 changes: 28 additions & 0 deletions tests/Feature/Social/PinterestControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@
]);
});

test('pinterest oauth callback splits space-separated approvedScopes before saving', function () {
session(['social_connect_workspace' => $this->workspace->id]);

$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('pin-user-xyz');
$socialiteUser->shouldReceive('getNickname')->andReturn('pinuser');
$socialiteUser->shouldReceive('getName')->andReturn('Pin User');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'test-access-token';
$socialiteUser->refreshToken = 'test-refresh-token';
$socialiteUser->expiresIn = 5184000;
// Pinterest's SocialiteProvider has scopeSeparator = ',' but Pinterest
// returns the granted scopes space-separated, so the provider doesn't
// split them and approvedScopes lands as a single-element array.
$socialiteUser->approvedScopes = ['boards:read boards:write pins:read pins:write user_accounts:read'];

Socialite::shouldReceive('driver')
->with('pinterest')
->andReturn(Mockery::mock(['user' => $socialiteUser]));

$this->actingAs($this->user)->get(route('app.social.pinterest.callback'));

$account = SocialAccount::where('platform_user_id', 'pin-user-xyz')->first();
expect($account->scopes)->toEqualCanonicalizing([
'boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read',
]);
});

test('pinterest callback fails with expired session', function () {
// No session data - simulating expired session

Expand Down
Loading