diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index d0de89f6..1a6b59d4 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -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, diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php index cb3be59a..50748554 100644 --- a/app/Http/Controllers/Auth/PinterestController.php +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -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, ]); diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 398d27bd..c95465a7 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -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 diff --git a/tests/Feature/Social/PinterestControllerTest.php b/tests/Feature/Social/PinterestControllerTest.php index 6ab5ff0a..4bce71e0 100644 --- a/tests/Feature/Social/PinterestControllerTest.php +++ b/tests/Feature/Social/PinterestControllerTest.php @@ -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