diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 200c3063..9dc08b66 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -4,17 +4,25 @@ namespace App\Console\Commands; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Jobs\RefreshSocialToken; use App\Models\SocialAccount; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; class RefreshExpiringTokens extends Command { protected $signature = 'social:refresh-expiring-tokens'; - protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)'; + protected $description = 'Proactively refresh social tokens before they expire'; + /** + * Rotating refresh_token platforms only need a short lead: verify() won't + * rotate a still-valid token, so we catch them right before or after expiry. + * Extension-model platforms (Instagram/Threads) can't be refreshed once + * expired, so they get a much wider lead to survive queue backlog. + */ public function handle(): void { $count = 0; @@ -22,7 +30,15 @@ public function handle(): void SocialAccount::query() ->where('status', Status::Connected) ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addHours(2)) + ->where(function (Builder $query) { + $query->where(function (Builder $extension) { + $extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues()) + ->where('token_expires_at', '<=', now()->addDay()); + })->orWhere(function (Builder $rotating) { + $rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues()) + ->where('token_expires_at', '<=', now()->addMinutes(30)); + }); + }) ->chunk(50, function ($accounts) use (&$count) { foreach ($accounts as $account) { RefreshSocialToken::dispatch($account); diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e66e2164..afcd02f3 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -261,6 +261,60 @@ public function requiresContent(): bool }; } + /** + * Whether this platform refreshes by extending the access_token itself + * (Instagram/Threads long-lived tokens) instead of exchanging a separate + * refresh_token. Extension-model tokens cannot be refreshed once expired, + * so they must be refreshed proactively while still valid — the opposite + * of rotating refresh_token platforms, which we avoid refreshing until + * they actually expire so we don't rotate a still-valid single-use token. + */ + public function extendsAccessTokenOnRefresh(): bool + { + return match ($this) { + self::Instagram, self::Threads => true, + default => false, + }; + } + + /** + * The `platform` column values of the platforms that refresh by extending + * their access token in place (Instagram and Threads — see + * extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn + * filters. Derived from extendsAccessTokenOnRefresh() so the two never drift. + * + * @return array + */ + public static function accessTokenExtendingPlatformValues(): array + { + return array_values(array_map( + fn (self $platform): string => $platform->value, + array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()), + )); + } + + /** + * The token lifetime, in seconds, to assume when the provider's OAuth + * response omits expires_in. Each value is that network's own documented + * default: + * + * - X: a 2-hour access token. + * - Instagram / Threads: Meta's 60-day long-lived token. + * + * Networks that always return expires_in (LinkedIn, TikTok, YouTube, + * Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or + * whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no + * fallback here and return null. + */ + public function defaultTokenTtlSeconds(): ?int + { + return match ($this) { + self::X => 7200, + self::Instagram, self::Threads => 5184000, + default => null, + }; + } + public function queue(): string { return 'social-'.$this->value; diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index 79931649..f8a0c054 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse $avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null; // Calculate token expiration (long-lived tokens last 60 days) - $expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds + $expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds(); $tokenExpiresAt = now()->addSeconds($expiresIn); $workspace->socialAccounts()->updateOrCreate( diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index c8445f7a..955009ea 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; +use App\Services\Social\TokenRedactor; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse if ($tokenResponse->failed()) { Log::error('Threads token exchange failed', [ 'status' => $tokenResponse->status(), - 'body' => $tokenResponse->body(), + 'body' => TokenRedactor::redact($tokenResponse->body()), ]); throw new \Exception('Failed to exchange token'); } @@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse 'access_token' => $shortLivedToken, ]); - $longLivedToken = $shortLivedToken; - $expiresIn = null; - - if ($longLivedResponse->successful()) { - $longLivedData = $longLivedResponse->json(); - $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? null; + if ($longLivedResponse->failed()) { + Log::error('Threads long-lived token exchange failed', [ + 'status' => $longLivedResponse->status(), + 'body' => TokenRedactor::redact($longLivedResponse->body()), + ]); + throw new \Exception('Failed to exchange long-lived token'); } + $longLivedData = $longLivedResponse->json(); + $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; + $expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds(); + // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ 'access_token' => $longLivedToken, @@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse 'avatar_url' => $avatarPath, 'access_token' => $longLivedToken, 'refresh_token' => null, - 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'token_expires_at' => now()->addSeconds($expiresIn), 'scopes' => $this->scopes, 'status' => Status::Connected, 'error_message' => null, diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 25b6b1bf..c3678c96 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {} public function handle(ConnectionVerifier $verifier): void { try { - $verifier->refreshToken($this->account); + if ($this->account->platform->extendsAccessTokenOnRefresh()) { + // Instagram/Threads extend the long-lived token itself and + // can't be refreshed once expired, so extend it while it's + // still valid instead of waiting for it to fail. + $verifier->refreshToken($this->account); + } else { + $verifier->verify($this->account); + } } catch (PlatformUnavailableException $e) { Log::warning('Token refresh skipped: platform unavailable', [ 'account_id' => $this->account->id, diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..21a3f573 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute ); } + /** + * Whether the token should be refreshed before use. Rotating-refresh-token + * platforms are only refreshed once actually expired, to avoid rotating a + * still-valid single-use refresh_token; extension-model platforms + * (Instagram/Threads) must be refreshed while still valid because their + * token can't be extended once expired. + */ + public function needsProactiveTokenRefresh(): bool + { + return $this->is_token_expired + || ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon); + } + protected function avatarUrl(): Attribute { return Attribute::make( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 5fd6f5e7..febc7858 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array $this->account = $postPlatform->socialAccount; $this->hasRetried = false; - if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { + if ($this->account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 90a36c6f..15d4850d 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Refresh token if needed - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 085699b1..071d554e 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -9,6 +9,7 @@ use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Discord\DiscordClient; +use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. if ($account->is_token_expired) { - $this->refreshToken($account); - - return $this->callVerifyEndpoint($account); + return $this->refreshThenVerify($account); } try { @@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool } catch (TokenExpiredException $e) { // Verify returned 401: the access_token is actually invalid. // Refresh and retry once with the new token. - try { - $this->refreshToken($account); - } catch (TokenExpiredException) { - throw $e; - } + return $this->refreshThenVerify($account, $e); + } + } + + /** + * Refresh the token, then verify with the new one. + * + * If either the refresh is rejected (4xx) or the verify that follows it hits + * a token a concurrent refresh has already rotated — including the sub-commit + * window where a lock-skipped refresh reloads a not-yet-persisted token — + * reload and, when another process has since persisted a fresh access_token, + * verify with that instead of giving up. X (and other providers that + * single-use their refresh_token) would otherwise disconnect a still-usable + * account whenever two refreshes race and one loses. + * + * @throws TokenExpiredException + * @throws PlatformUnavailableException + */ + private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool + { + $accessTokenBeforeRefresh = $account->access_token; + + try { + $this->refreshToken($account); return $this->callVerifyEndpoint($account); + } catch (TokenExpiredException $e) { + $account->refresh(); + + if ($account->access_token !== $accessTokenBeforeRefresh) { + return $this->callVerifyEndpoint($account); + } + + throw $original ?? $e; } } @@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void $account->update([ 'access_token' => data_get($data, 'access_token'), 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void private function refreshThreadsToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $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, - ])); + $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, + ]), + GraphError::indicatesInvalidToken(...), + ); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void private function refreshInstagramToken(SocialAccount $account): void { - $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, - ])); + $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, + ]), + GraphError::indicatesInvalidToken(...), + ); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Instagram access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Instagram access token is invalid or expired'); } return $response->successful(); @@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Facebook access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Facebook access token is invalid or expired'); } return $response->successful(); @@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool $body = $response->json() ?? []; - if (isset($body['error'])) { - $errorCode = $body['error']['code'] ?? null; - $errorType = $body['error']['type'] ?? null; - - if ($errorType === 'OAuthException' || $errorCode === 190) { - throw new TokenExpiredException('Threads access token is invalid or expired'); - } + if (GraphError::indicatesInvalidToken($body)) { + throw new TokenExpiredException('Threads access token is invalid or expired'); } return $response->successful(); diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 50c20c88..42356e1e 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 23e684d8..176bdcc3 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; $this->baseUrl = $account->platform->instagramGraphBaseUrl(); - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 71fb4355..5573a749 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php new file mode 100644 index 00000000..e0097e5b --- /dev/null +++ b/app/Services/Social/Meta/GraphError.php @@ -0,0 +1,30 @@ +|null $body + */ + public static function indicatesInvalidToken(?array $body): bool + { + return data_get($body, 'error.code') === 190; + } +} diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 082963a2..7b3bc6ce 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c0669c53..b56f0b97 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, */ public function getBoards(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index ef196477..67c572f6 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index fe256fa3..d3740bd1 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 37861978..7cc7d5e0 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array private function fetchMetricsFromApi(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 7571d067..ce058674 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array */ private function fetchFresh(SocialAccount $account): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 13bf6991..0c50fa17 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php index 70b023e2..d20839e4 100644 --- a/app/Services/Social/TokenRefreshClient.php +++ b/app/Services/Social/TokenRefreshClient.php @@ -16,13 +16,18 @@ * Normalizes the failure modes of an OAuth token-refresh request: * * - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException - * - HTTP 5xx → PlatformUnavailableException - * - HTTP 4xx → TokenExpiredException + * - HTTP 5xx or 429 → PlatformUnavailableException + * - other HTTP 4xx → TokenExpiredException + * + * Providers that return rate-limit or transient errors as an ordinary 4xx + * (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to + * `send()`; only a genuinely dead token then disconnects, while every other + * 4xx is treated as transient (PlatformUnavailableException). * * Callers configure the actual HTTP call through the closure passed to * `send()`, so platform-specific quirks (form vs JSON body, auth headers, * basic auth, etc.) stay where they belong — in the per-platform refresh - * method — while the failure semantics are uniform across providers. + * method. */ class TokenRefreshClient { @@ -35,11 +40,14 @@ public static function for(Platform $platform): self /** * @param Closure():Response $request + * @param (Closure(array|null):bool)|null $isTokenInvalid Given the parsed + * response body, returns whether a non-5xx/429 failure means the token itself is dead. + * When it returns false, the failure is treated as transient (PlatformUnavailableException). * * @throws PlatformUnavailableException * @throws TokenExpiredException */ - public function send(Closure $request): Response + public function send(Closure $request, ?Closure $isTokenInvalid = null): Response { $name = $this->platform->label(); @@ -62,6 +70,14 @@ public function send(Closure $request): Response ]); $body = $response->json(); + + if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) { + throw new PlatformUnavailableException( + "{$name} API returned {$response->status()} during token refresh", + $response->status(), + ); + } + $message = data_get($body, 'error_description') ?? data_get($body, 'error.message') ?? "Failed to refresh {$name} token"; diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index c562b2a8..618a87ef 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5a535605..6b82f1a7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - // Refresh token if expired or expiring soon - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c4eb8342..f43b16db 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } @@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..f3f6e443 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; - if ($account->is_token_expired || $account->is_token_expiring_soon) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/routes/console.php b/routes/console.php index 0fa9db84..dd2b3ef6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -14,7 +14,7 @@ Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command(CheckSocialConnections::class)->daily()->withoutOverlapping()->onOneServer(); -Schedule::command(RefreshExpiringTokens::class)->hourly()->withoutOverlapping()->onOneServer(); +Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index 5656d28b..957f8f7f 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -9,48 +9,73 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Queue; -test('it dispatches refresh jobs for tokens expiring within 2 hours or already expired', function () { +test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () { Queue::fake(); $workspace = Workspace::factory()->create(); - // Should be refreshed (expires in 1 hour) - $expiringSoon = SocialAccount::factory()->create([ + // Rotating platform expiring in 15 minutes — inside the 30-minute window. + $rotatingSoon = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::LinkedIn, 'status' => Status::Connected, + 'token_expires_at' => now()->addMinutes(15), + ]); + + // Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window. + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::X, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + // Extension platform expiring in 1 hour — inside the wide 24-hour window. + // (On the old shared 30-minute window this lapsed under queue backlog.) + $extensionSoon = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, 'token_expires_at' => now()->addHour(), ]); - // Should NOT be refreshed (expires in 5 hours — outside the proactive window) + // Extension platform expiring in 12 hours — still inside the 24-hour window. + $extensionLater = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Threads, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHours(12), + ]); + + // Extension platform expiring in 2 days — OUTSIDE the 24-hour window. SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::Instagram, 'status' => Status::Connected, - 'token_expires_at' => now()->addHours(5), + 'token_expires_at' => now()->addDays(2), ]); - // SHOULD be refreshed (already expired — last-chance attempt before the - // refresh_token also dies at the provider). - $justExpired = SocialAccount::factory()->create([ + // Rotating platform already expired — last-chance attempt before the + // refresh_token also dies at the provider. + $rotatingExpired = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::TikTok, 'status' => Status::Connected, 'token_expires_at' => now()->subHour(), ]); - // Should NOT be refreshed (disconnected) + // Disconnected — never refreshed. SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, - 'platform' => Platform::X, + 'platform' => Platform::Pinterest, 'status' => Status::Disconnected, 'token_expires_at' => now()->addHour(), ]); - // Should NOT be refreshed (already token expired — daily verify handles these) + // Already token expired — daily verify handles these. SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, - 'platform' => Platform::Pinterest, + 'platform' => Platform::YouTube, 'status' => Status::TokenExpired, 'token_expires_at' => now()->subHour(), ]); @@ -58,9 +83,40 @@ $this->artisan('social:refresh-expiring-tokens') ->assertSuccessful(); - Queue::assertPushed(RefreshSocialToken::class, 2); - Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id); - Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $justExpired->id); + Queue::assertPushed(RefreshSocialToken::class, 4); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingSoon->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionSoon->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionLater->id); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingExpired->id); +}); + +test('extension-model platforms get a wider refresh window than rotating platforms', function () { + Queue::fake(); + + $workspace = Workspace::factory()->create(); + + // Same expiry (1 hour out) for both — only the extension-model account + // should be dispatched, because it can't be refreshed once expired. + $extension = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + $rotating = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::X, + 'status' => Status::Connected, + 'token_expires_at' => now()->addHour(), + ]); + + $this->artisan('social:refresh-expiring-tokens') + ->assertSuccessful(); + + Queue::assertPushed(RefreshSocialToken::class, 1); + Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extension->id); + Queue::assertNotPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotating->id); }); test('it dispatches nothing when no tokens are expiring', function () { diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index c9a9226d..898950c3 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; @@ -11,6 +12,7 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; @@ -24,22 +26,114 @@ ]); }); -test('refresh job calls refreshToken (not verify) on the verifier', function () { +test('refresh job routes through verify (access-token-first) not refreshToken', function () { $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->with( + $verifier->shouldReceive('verify')->once()->with( Mockery::on(fn ($account) => $account->id === $this->account->id) ); - $verifier->shouldNotReceive('verify'); + $verifier->shouldNotReceive('refreshToken'); app()->instance(ConnectionVerifier::class, $verifier); (new RefreshSocialToken($this->account))->handle($verifier); }); +test('proactive refresh does NOT rotate the X refresh token while the access token still works', function () { + Http::fake([ + config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'should-not-be-used', + 'refresh_token' => 'should-not-be-used', + 'expires_in' => 7200, + ], 200), + ]); + + // Token is "expiring soon" (inside the proactive window) but still valid. + $this->account->update([ + 'token_expires_at' => now()->addMinutes(20), + 'refresh_token' => 'original-refresh-token', + ]); + + (new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class)); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token')); + expect($this->account->fresh()->refresh_token)->toBe('original-refresh-token'); + expect($this->account->fresh()->status)->toBe(Status::Connected); +}); + +test('proactive refresh EXTENDS a still-valid Instagram token (extension-model platform)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'access_token' => 'old-ig-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'extended-ig-token', + 'expires_in' => 5184000, + ], 200), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + // Instagram/Threads extend the token itself and can't refresh once expired, + // so a still-valid token IS extended proactively — unlike rotating platforms. + Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); + expect($account->fresh()->access_token)->toBe('extended-ig-token'); +}); + +test('proactive refresh EXTENDS a still-valid Threads token (extension-model platform)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads, + 'status' => Status::Connected, + 'access_token' => 'old-threads-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'extended-threads-token', + 'expires_in' => 5184000, + ], 200), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); + expect($account->fresh()->access_token)->toBe('extended-threads-token'); +}); + +test('proactive refresh does NOT disconnect Instagram on a Meta rate-limit (400 OAuthException code 4)', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'status' => Status::Connected, + 'access_token' => 'valid-ig-token', + 'token_expires_at' => now()->addMinutes(20), + ]); + + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + (new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class)); + + // A rate-limit is transient — the still-valid token must stay Connected. + expect($account->fresh()->status)->toBe(Status::Connected); + expect($account->fresh()->access_token)->toBe('valid-ig-token'); +}); + test('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow( + $verifier->shouldReceive('verify')->once()->andThrow( new TokenExpiredException('refresh_token revoked') ); app()->instance(ConnectionVerifier::class, $verifier); @@ -61,7 +155,7 @@ }); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow(new RuntimeException('network blip')); + $verifier->shouldReceive('verify')->once()->andThrow(new RuntimeException('network blip')); app()->instance(ConnectionVerifier::class, $verifier); (new RefreshSocialToken($this->account))->handle($verifier); @@ -79,7 +173,7 @@ }); $verifier = mock(ConnectionVerifier::class); - $verifier->shouldReceive('refreshToken')->once()->andThrow( + $verifier->shouldReceive('verify')->once()->andThrow( new PlatformUnavailableException('X API returned 503 during token refresh', 503) ); app()->instance(ConnectionVerifier::class, $verifier); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 332d6860..9d523ec5 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Status; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; @@ -283,6 +284,42 @@ Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); +test('threads refresh records a 60-day expiry when the response omits expires_in', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'new-threads-token', + ], 200), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addHours(2), + ]); + + (new ConnectionVerifier)->refreshToken($account); + + $account->refresh(); + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); + +test('instagram refresh records a 60-day expiry when the response omits expires_in', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'access_token' => 'new-instagram-token', + ], 200), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addHours(2), + ]); + + (new ConnectionVerifier)->refreshToken($account); + + $account->refresh(); + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); + test('does NOT refresh proactively when token still works (lazy refresh)', function () { Http::fake([ 'api.linkedin.com/*' => Http::response(['sub' => '123'], 200), @@ -414,6 +451,63 @@ expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); }); +test('does not disconnect when a concurrent refresh already rotated the token (lost-rotation race)', function () { + Http::fake([ + // Our stale refresh_token is rejected — a concurrent process already used it. + config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + // But the access_token the winning refresh persisted still works. + config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'status' => Status::Connected, + 'access_token' => 'stale-token', + 'refresh_token' => 'already-rotated', + 'token_expires_at' => now()->subHour(), + ]); + + // Simulate the concurrent refresh: a separate instance persists a fresh, + // valid token (through the encrypted cast) while our in-memory copy stays + // the stale, expired one. + SocialAccount::find($account->id)->update([ + 'access_token' => 'fresh-token', + 'token_expires_at' => now()->addHours(2), + ]); + + expect((new ConnectionVerifier)->verify($account))->toBeTrue(); + expect($account->fresh()->status)->toBe(Status::Connected); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me') + && $request->header('Authorization')[0] === 'Bearer fresh-token'); +}); + +test('does not disconnect when the verify after a refresh 401s once but a fresh token is available', function () { + Http::fake([ + // Refresh succeeds and rotates the token... + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'refreshed-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 7200, + ], 200), + // ...but the verify that follows 401s once (the sub-commit window where a + // lock-skipped refresh reloads a not-yet-persisted token) before + // succeeding on the reload + retry. + config('trypost.platforms.x.api').'/users/me' => Http::sequence() + ->push(['error' => 'unauthorized'], 401) + ->push(['data' => ['id' => '123']], 200), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'status' => Status::Connected, + 'access_token' => 'stale-token', + 'refresh_token' => 'old-refresh', + 'token_expires_at' => now()->subHour(), + ]); + + expect((new ConnectionVerifier)->verify($account))->toBeTrue(); + expect($account->fresh()->status)->toBe(Status::Connected); +}); + test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), @@ -494,3 +588,65 @@ expect((new ConnectionVerifier)->verify($account))->toBeFalse(); }); + +test('instagram refresh treats a Meta rate-limit (400 OAuthException code 4) as transient, not a dead token', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('threads refresh treats a Meta rate-limit (400 OAuthException code 17) as transient, not a dead token', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17], + ], 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('instagram refresh treats code 190 as a genuinely expired token', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + // Not expired, so verify hits the endpoint directly (no refresh). + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + // A rate-limit must NOT raise TokenExpiredException (which would disconnect); + // verify returns false and the caller leaves the account connected. + expect((new ConnectionVerifier)->verify($account))->toBeFalse(); +}); diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index 23025e4d..a08c6899 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -804,11 +804,12 @@ protected function processingPollSeconds(): int ->toThrow(TokenExpiredException::class); }); -test('linkedin publisher refreshes the token when it is expiring soon', function () { +test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () { $this->socialAccount->update([ 'token_expires_at' => now()->addMinutes(5), 'refresh_token' => 'refresh-token-123', ]); + $originalAccessToken = $this->socialAccount->access_token; Http::fake([ config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ @@ -822,8 +823,11 @@ protected function processingPollSeconds(): int $result = $this->publisher->publish($this->postPlatform); expect($result['id'])->toBe('urn:li:share:soon'); + + // A still-valid token is used as-is — the single-use refresh_token is not rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken')); $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); }); test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () { diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 9847eaa5..b684199a 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -65,6 +65,31 @@ }); }); +test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->addMinutes(5), + 'refresh_token' => 'original-refresh-token', + ]); + $originalAccessToken = $this->socialAccount->access_token; + + Http::fake([ + config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'should-not-be-used', + 'refresh_token' => 'should-not-be-used', + 'expires_in' => 7200, + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // X single-use refresh tokens: a still-valid access_token must NOT be rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token')); + $this->socialAccount->refresh(); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); + expect($this->socialAccount->refresh_token)->toBe('original-refresh-token'); +}); + test('x publisher uses bearer token authentication', function () { Http::fake([ 'https://api.x.com/2/tweets' => Http::response([ diff --git a/tests/Feature/Services/XPublisherTest.php b/tests/Feature/Services/XPublisherTest.php index 24bae974..13dedfb1 100644 --- a/tests/Feature/Services/XPublisherTest.php +++ b/tests/Feature/Services/XPublisherTest.php @@ -143,11 +143,12 @@ ->toThrow(TokenExpiredException::class); }); -test('x publisher refreshes token when expiring soon', function () { +test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () { $this->socialAccount->update([ 'token_expires_at' => now()->addMinutes(5), 'refresh_token' => 'refresh-token-123', ]); + $originalAccessToken = $this->socialAccount->access_token; Http::fake([ '*/2/oauth2/token' => Http::response([ @@ -164,8 +165,11 @@ $result = $publisher->publish($this->postPlatform); expect($result['id'])->toBe('tweet-123'); + + // A still-valid token is used as-is — the single-use refresh_token is not rotated. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/oauth2/token')); $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); + expect($this->socialAccount->access_token)->toBe($originalAccessToken); }); test('x publisher handles 403 error as generic error', function () { diff --git a/tests/Feature/Social/ThreadsControllerTest.php b/tests/Feature/Social/ThreadsControllerTest.php index 19f900f1..3ccd96a4 100644 --- a/tests/Feature/Social/ThreadsControllerTest.php +++ b/tests/Feature/Social/ThreadsControllerTest.php @@ -217,3 +217,75 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1); }); + +test('threads callback fails the connect when the long-lived token exchange fails', function () { + $state = bin2hex(random_bytes(16)); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'threads_oauth_state' => $state, + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([ + 'access_token' => 'short-lived-token', + 'user_id' => '123456789', + ], 200), + config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response('upstream error', 503), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ + 'code' => 'test-auth-code', + 'state' => $state, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting'))); + + // A short-lived-only account would silently die within the hour and never be + // picked up by the refresh cron, so the connect must not persist one. + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Threads->value, + ]); +}); + +test('threads callback records a 60-day expiry when the long-lived exchange omits expires_in', function () { + $state = bin2hex(random_bytes(16)); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'threads_oauth_state' => $state, + ]); + + Http::fake([ + config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([ + 'access_token' => 'short-lived-token', + 'user_id' => '123456789', + ], 200), + config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response([ + 'access_token' => 'long-lived-token', + ], 200), + config('trypost.platforms.threads.graph_api').'/123456789*' => Http::response([ + 'id' => '123456789', + 'username' => 'testuser', + 'name' => 'Test User', + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ + 'code' => 'test-auth-code', + 'state' => $state, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $account = $this->workspace->socialAccounts() + ->where('platform', Platform::Threads) + ->first(); + + expect($account->token_expires_at)->not->toBeNull(); + expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue(); +}); diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index d28bbf70..9be609c7 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Notification\Type; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Events\NotificationCreated; use App\Jobs\SendNotification; @@ -20,6 +21,33 @@ $this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]); }); +// ---- needsProactiveTokenRefresh ---- + +test('needsProactiveTokenRefresh: rotating platforms refresh only when expired, extension platforms while still valid', function () { + // Rotating (X): a still-valid token that is merely expiring soon is NOT refreshed. + $xValid = SocialAccount::factory()->x()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->addMinutes(10), + ]); + expect($xValid->needsProactiveTokenRefresh())->toBeFalse(); + + // Rotating (X): once actually expired, it is refreshed. + $xExpired = SocialAccount::factory()->x()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->subMinute(), + ]); + expect($xExpired->needsProactiveTokenRefresh())->toBeTrue(); + + // Extension-model (Instagram): a still-valid token that is expiring soon MUST + // be refreshed while valid — its token can't be extended once expired. + $igValid = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'token_expires_at' => now()->addMinutes(10), + ]); + expect($igValid->needsProactiveTokenRefresh())->toBeTrue(); +}); + // ---- markAsTokenExpired ---- test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () { diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 5c4f94d1..340d302f 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -85,6 +85,24 @@ expect(Platform::Pinterest->supportsTextOnly())->toBeFalse(); }); +test('platform exposes the correct default token TTL fallback', function () { + // X access tokens live 2 hours. + expect(Platform::X->defaultTokenTtlSeconds())->toBe(7200); + + // Instagram and Threads use Meta's 60-day long-lived token. + expect(Platform::Instagram->defaultTokenTtlSeconds())->toBe(5184000); + expect(Platform::Threads->defaultTokenTtlSeconds())->toBe(5184000); + + // Networks that always return expires_in, set a fixed lifetime directly, or + // never expire have no fallback here. + expect(Platform::LinkedIn->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::TikTok->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::YouTube->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Pinterest->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Bluesky->defaultTokenTtlSeconds())->toBeNull(); + expect(Platform::Facebook->defaultTokenTtlSeconds())->toBeNull(); +}); + test('platform is enabled by default', function () { expect(Platform::LinkedIn->isEnabled())->toBeTrue(); expect(Platform::Instagram->isEnabled())->toBeTrue(); diff --git a/tests/Unit/Services/Social/Meta/GraphErrorTest.php b/tests/Unit/Services/Social/Meta/GraphErrorTest.php new file mode 100644 index 00000000..f1d79931 --- /dev/null +++ b/tests/Unit/Services/Social/Meta/GraphErrorTest.php @@ -0,0 +1,35 @@ + ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], + ]))->toBeTrue(); +}); + +test('rate-limit codes carried as OAuthException do NOT indicate an invalid token', function () { + // Meta returns rate limits as HTTP 4xx with type OAuthException — they must + // stay transient so a throttle never disconnects a still-valid token. + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ]))->toBeFalse(); + + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17], + ]))->toBeFalse(); +}); + +test('transient codes do NOT indicate an invalid token', function () { + expect(GraphError::indicatesInvalidToken([ + 'error' => ['message' => 'Service temporarily unavailable', 'code' => 2], + ]))->toBeFalse(); +}); + +test('a body with no error, or a null body, does not indicate an invalid token', function () { + expect(GraphError::indicatesInvalidToken(null))->toBeFalse(); + expect(GraphError::indicatesInvalidToken([]))->toBeFalse(); + expect(GraphError::indicatesInvalidToken(['data' => ['id' => '123']]))->toBeFalse(); +});