diff --git a/app/Exceptions/PlatformUnavailableException.php b/app/Exceptions/PlatformUnavailableException.php new file mode 100644 index 00000000..27c10e4b --- /dev/null +++ b/app/Exceptions/PlatformUnavailableException.php @@ -0,0 +1,23 @@ +refreshToken($this->account); + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). Leave the account alone — + // next scheduled run will try again. Critically, do NOT mark + // the account expired: that would trigger a false-positive + // "reconnect your account" notification. + Log::warning('Token refresh skipped: platform unavailable', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); } catch (TokenExpiredException $e) { // refresh_token rejected by the provider (revoked / rotated / // expired beyond refresh). Mark the account so the user is diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index d635ed97..5174b13d 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -7,6 +7,7 @@ use App\Enums\Notification\Channel; use App\Enums\Notification\Type; use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Mail\WorkspaceConnectionsDisconnected; use App\Models\SocialAccount; @@ -63,6 +64,16 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco try { $verifier->verify($account); + return true; + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). The account's token is not + // provably bad — skip silently and try again next pass. + Log::warning('Social account verification skipped: platform unavailable', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $e->getMessage(), + ]); + return true; } catch (TokenExpiredException $e) { Log::warning('Social account connection is invalid', [ diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index d65a493d..e5c9fc17 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -5,11 +5,11 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; class ConnectionVerifier { @@ -17,6 +17,7 @@ class ConnectionVerifier * Verify that a social account connection is still valid. * * @throws TokenExpiredException if the connection is invalid + * @throws PlatformUnavailableException if the platform's API is down */ public function verify(SocialAccount $account): bool { @@ -27,6 +28,9 @@ public function verify(SocialAccount $account): bool // LinkedIn, etc.) invalidate the previous refresh_token on each // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. + // + // PlatformUnavailableException from refresh propagates naturally so + // the caller can distinguish "platform is down" from "token bad". if ($account->is_token_expired) { $this->refreshToken($account); @@ -113,17 +117,13 @@ private function refreshLinkedInToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh LinkedIn token'); - } + $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin.client_id'), + 'client_secret' => config('services.linkedin.client_secret'), + ])); $data = $response->json(); @@ -145,17 +145,12 @@ private function refreshXToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->post(config('trypost.platforms.x.api').'/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh X token'); - } + ])); $data = $response->json(); @@ -170,13 +165,16 @@ private function refreshXToken(SocialAccount $account): void private function refreshBlueskyToken(SocialAccount $account): void { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); + $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh token first - $response = Http::withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + // Try refresh token first. Connection errors / 5xx surface as + // PlatformUnavailableException (Bluesky is down — don't touch the + // account's status). 4xx falls through to the re-auth fallback. + try { + $response = $client->send(fn () => Http::withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - if ($response->successful()) { $data = $response->json(); $account->update([ 'access_token' => data_get($data, 'accessJwt'), @@ -187,35 +185,29 @@ private function refreshBlueskyToken(SocialAccount $account): void $account->refresh(); return; + } catch (TokenExpiredException) { + // refresh token was rejected (4xx) — fall back to re-auth below } - // If refresh fails, re-authenticate with stored credentials if (isset($account->meta['password'])) { try { - $password = decrypt($account->meta['password']); - $identifier = $account->meta['identifier']; + $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $account->meta['identifier'], + 'password' => decrypt($account->meta['password']), + ])); - $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $identifier, - 'password' => $password, + $data = $reauth->json(); + $account->update([ + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), + 'token_expires_at' => now()->addHours(2), ]); - if ($response->successful()) { - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); + $account->refresh(); - $account->refresh(); - - return; - } - } catch (\Exception $e) { - Log::error('ConnectionVerifier: Bluesky re-authentication failed', [ - 'error' => $e->getMessage(), - ]); + return; + } catch (TokenExpiredException) { + // re-auth rejected with stored credentials — fall through } } @@ -228,17 +220,13 @@ private function refreshYouTubeToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + ])); $data = $response->json(); @@ -256,17 +244,13 @@ private function refreshTikTokToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh TikTok token'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + ])); $data = $response->json(); @@ -287,18 +271,13 @@ private function refreshPinterestToken(SocialAccount $account): void $credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret')); - $response = Http::withHeaders([ + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([ 'Authorization' => "Basic {$credentials}", 'Content-Type' => 'application/x-www-form-urlencoded', ])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Pinterest token'); - } + ])); $data = $response->json(); @@ -314,15 +293,10 @@ private function refreshPinterestToken(SocialAccount $account): void private function refreshThreadsToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Threads token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -338,15 +312,10 @@ private function refreshThreadsToken(SocialAccount $account): void private function refreshInstagramToken(SocialAccount $account): void { - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Instagram token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -524,7 +493,7 @@ private function verifyPinterest(SocialAccount $account): bool private function verifyBluesky(SocialAccount $account): bool { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); $response = Http::withToken($account->access_token) ->get("{$service}/xrpc/app.bsky.actor.getProfile", [ @@ -554,23 +523,4 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } - - private function redactBody(string $body): string - { - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - '/"token"\s*:\s*"([^"]+)"/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - '"token":"[REDACTED]"', - ], - $body - ); - } } diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php new file mode 100644 index 00000000..e1959897 --- /dev/null +++ b/app/Services/Social/TokenRefreshClient.php @@ -0,0 +1,87 @@ +platform->label(); + + try { + $response = $request(); + } catch (ConnectionException $e) { + throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}"); + } + + if ($response->serverError()) { + throw new PlatformUnavailableException( + "{$name} API returned {$response->status()} during token refresh", + $response->status(), + ); + } + + if ($response->failed()) { + Log::error("TokenRefreshClient: {$name} token refresh failed", [ + 'body' => $this->redactBody($response->body()), + ]); + throw new TokenExpiredException("Failed to refresh {$name} token"); + } + + return $response; + } + + private function redactBody(string $body): string + { + return preg_replace( + [ + '/access_token=([^&"\s]+)/', + '/"access_token"\s*:\s*"([^"]+)"/', + '/Bearer\s+\S+/', + '/"token"\s*:\s*"([^"]+)"/', + ], + [ + 'access_token=[REDACTED]', + '"access_token":"[REDACTED]"', + 'Bearer [REDACTED]', + '"token":"[REDACTED]"', + ], + $body + ); + } +} diff --git a/config/trypost.php b/config/trypost.php index 9ab2e2b9..29fcfb95 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -63,6 +63,8 @@ 'linkedin' => [ 'enabled' => env('LINKEDIN_ENABLED', true), 'api' => env('LINKEDIN_API', 'https://api.linkedin.com'), + // OAuth host is different from the data API (api.linkedin.com). + 'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'), ], 'linkedin-page' => [ 'enabled' => env('LINKEDIN_PAGE_ENABLED', true), @@ -80,6 +82,7 @@ 'enabled' => env('YOUTUBE_ENABLED', true), 'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'), 'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'), + 'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'), ], 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), @@ -108,6 +111,8 @@ 'bluesky' => [ 'enabled' => env('BLUESKY_ENABLED', true), 'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'), + // Default PDS used when the account has no `meta.service` override. + 'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'), ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 41b956c1..172076a7 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\RefreshSocialToken; use App\Jobs\SendNotification; @@ -67,3 +68,25 @@ expect($this->account->fresh()->status)->toBe(Status::Connected); }); + +test('refresh job does NOT mark account expired when platform is unavailable', function () { + Queue::fake(); + + Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) { + return $message === 'Token refresh skipped: platform unavailable' + && $context['account_id'] === $this->account->id + && str_contains($context['error'], '503'); + }); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('refreshToken')->once()->andThrow( + new PlatformUnavailableException('X API returned 503 during token refresh', 503) + ); + app()->instance(ConnectionVerifier::class, $verifier); + + (new RefreshSocialToken($this->account))->handle($verifier); + + // Critically: account status stays Connected, no notification dispatched. + expect($this->account->fresh()->status)->toBe(Status::Connected); + Queue::assertNotPushed(SendNotification::class); +}); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index cbadf68d..9d025385 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\ConnectionVerifier; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; test('verifies account without refresh when token is not expired', function () { @@ -360,3 +362,71 @@ expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); + +test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () { + Http::fake([ + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => ['service' => 'https://bsky.social'], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('connection failure during refresh raises PlatformUnavailableException', function () { + Http::fake([ + 'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), + ]); + + $account = SocialAccount::factory()->youtube()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('4xx during refresh keeps raising TokenExpiredException', function () { + Http::fake([ + 'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class); +}); + +test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { + Http::fake([ + // Both endpoints return 5xx — the platform is genuinely down. + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + 'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => [ + 'service' => 'https://bsky.social', + 'identifier' => 'user.bsky.social', + 'password' => encrypt('app-password'), + ], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); diff --git a/tests/Feature/VerifyWorkspaceConnectionsTest.php b/tests/Feature/VerifyWorkspaceConnectionsTest.php index 2f351c78..5eaff679 100644 --- a/tests/Feature/VerifyWorkspaceConnectionsTest.php +++ b/tests/Feature/VerifyWorkspaceConnectionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\VerifyWorkspaceConnections; use App\Mail\WorkspaceConnectionsDisconnected; @@ -121,6 +122,26 @@ }); }); +test('job does NOT disconnect or email when platform is unavailable', function () { + Mail::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->andThrow( + new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503) + ); + + app()->instance(ConnectionVerifier::class, $verifier); + + VerifyWorkspaceConnections::dispatch($workspace); + + // Status untouched, no notification email. + expect($account->fresh()->status)->toBe(Status::Connected); + Mail::assertNothingQueued(); +}); + test('job skips already disconnected accounts', function () { Mail::fake();