From 4e80fd54b7bacbf76af8b47683e746899e5e97d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?= Date: Wed, 1 Jul 2026 12:16:15 -0300 Subject: [PATCH 01/14] fix(media): don't 500 when the Unsplash/Giphy key is set to null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config('services.unsplash.access_key', '')` only falls back to '' when the key is *absent* — if the env var is present but empty, config returns null, and assigning null to the `string` property throws a TypeError, taking the whole request down with a 500 during media search. Cast to `(string)` so a null/empty key degrades gracefully to '' and the service returns an empty result set (as it already does for a missing key) instead of throwing. Covered by a regression test for both services. --- app/Services/GiphyService.php | 2 +- app/Services/UnsplashService.php | 2 +- .../Services/MediaSearchKeyFallbackTest.php | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Services/MediaSearchKeyFallbackTest.php diff --git a/app/Services/GiphyService.php b/app/Services/GiphyService.php index e60ff667..f5e674d4 100644 --- a/app/Services/GiphyService.php +++ b/app/Services/GiphyService.php @@ -15,7 +15,7 @@ class GiphyService public function __construct() { - $this->apiKey = config('services.giphy.api_key', ''); + $this->apiKey = (string) config('services.giphy.api_key', ''); } /** diff --git a/app/Services/UnsplashService.php b/app/Services/UnsplashService.php index 5ac1ad9a..db16237e 100644 --- a/app/Services/UnsplashService.php +++ b/app/Services/UnsplashService.php @@ -15,7 +15,7 @@ class UnsplashService public function __construct() { - $this->accessKey = config('services.unsplash.access_key', ''); + $this->accessKey = (string) config('services.unsplash.access_key', ''); } /** diff --git a/tests/Unit/Services/MediaSearchKeyFallbackTest.php b/tests/Unit/Services/MediaSearchKeyFallbackTest.php new file mode 100644 index 00000000..6fa85c47 --- /dev/null +++ b/tests/Unit/Services/MediaSearchKeyFallbackTest.php @@ -0,0 +1,22 @@ + null]); + + $result = (new UnsplashService)->search('cats'); + + expect($result)->toBe(['results' => [], 'total' => 0, 'total_pages' => 0]); +}); + +test('giphy service tolerates a null api key without throwing', function () { + config(['services.giphy.api_key' => null]); + + $result = (new GiphyService)->search('cats'); + + expect($result)->toBe(['results' => [], 'total' => 0, 'total_pages' => 0]); +}); From 7dab106f063b2e1e9e0dbfc50e4bdc9c56030e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?= Date: Wed, 1 Jul 2026 13:49:48 -0300 Subject: [PATCH 02/14] fix(workspace): default content_language to the app locale, not 'en' When a workspace is created without an explicit content_language, `data_get($data, 'content_language')` returns null, array_filter drops the key, and the column falls back to its DB default of 'en'. So AI content and notifications came out in English even when the app was running in another locale. Default to `app()->getLocale()` instead, so a new workspace inherits the user's current language. An explicit content_language still wins. --- app/Actions/Workspace/CreateWorkspace.php | 2 +- .../Actions/Workspace/CreateWorkspaceTest.php | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index 4c42fe67..3c7c2df0 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -24,7 +24,7 @@ public static function execute(User $user, array $data): Workspace 'brand_color' => data_get($data, 'brand_color'), 'background_color' => data_get($data, 'background_color'), 'text_color' => data_get($data, 'text_color'), - 'content_language' => data_get($data, 'content_language'), + 'content_language' => data_get($data, 'content_language', app()->getLocale()), ], static fn ($value): bool => $value !== null); $workspace = DB::transaction(function () use ($user, $attributes): Workspace { diff --git a/tests/Feature/Actions/Workspace/CreateWorkspaceTest.php b/tests/Feature/Actions/Workspace/CreateWorkspaceTest.php index a31bf419..7c57efd3 100644 --- a/tests/Feature/Actions/Workspace/CreateWorkspaceTest.php +++ b/tests/Feature/Actions/Workspace/CreateWorkspaceTest.php @@ -42,6 +42,28 @@ expect($member?->pivot->role)->toBe(Role::Admin->value); }); +test('CreateWorkspace inherits the app locale as content_language when none is given', function () { + app()->setLocale('pt-BR'); + + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + + $workspace = CreateWorkspace::execute($user, ['name' => 'Acme']); + + expect($workspace->content_language)->toBe('pt-BR'); +}); + +test('CreateWorkspace keeps an explicit content_language over the app locale', function () { + app()->setLocale('pt-BR'); + + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + + $workspace = CreateWorkspace::execute($user, ['name' => 'Acme', 'content_language' => 'es']); + + expect($workspace->content_language)->toBe('es'); +}); + test('CreateWorkspace ignores unknown extra keys like logo_url', function () { $account = Account::factory()->create(); $user = User::factory()->create(['account_id' => $account->id]); From 974c076e8654a4be58142c5acdebe70d53be7358 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 2 Jul 2026 09:21:31 -0300 Subject: [PATCH 03/14] fix(tracking): fire begin_checkout when onboarding checkout starts trackBeginCheckout was defined but never called, so the begin_checkout (GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire it into the onboarding Connect submit handler, before the redirect to Stripe, and pass the workspace plan from the controller so the event carries plan name + interval. --- .../Controllers/App/OnboardingController.php | 6 ++++++ resources/js/pages/onboarding/Connect.vue | 6 ++++++ .../Onboarding/OnboardingControllerTest.php | 16 ++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index 80475a77..e78a502f 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -150,9 +150,15 @@ public function connect(Request $request): Response|RedirectResponse 'network' => $platform->network(), ])->values(); + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + return Inertia::render('onboarding/Connect', [ 'platforms' => $platforms, 'accounts' => SocialAccountResource::collection($accounts)->resolve(), + 'plan' => [ + 'name' => $plan->name, + 'interval' => 'monthly', + ], ]); } diff --git a/resources/js/pages/onboarding/Connect.vue b/resources/js/pages/onboarding/Connect.vue index 4b9e914a..80ae728a 100644 --- a/resources/js/pages/onboarding/Connect.vue +++ b/resources/js/pages/onboarding/Connect.vue @@ -9,14 +9,18 @@ import NetworkConnectGrid, { type ConnectedAccount, } from '@/components/accounts/NetworkConnectGrid.vue'; import { Button } from '@/components/ui/button'; +import { useTracking } from '@/composables/useTracking'; const props = defineProps<{ platforms: AvailablePlatform[]; accounts: ConnectedAccount[]; + plan: { name: string; interval: string }; }>(); const form = useForm({}); +const { trackBeginCheckout } = useTracking(); + const hasConnected = computed((): boolean => props.accounts.length > 0); const submit = (): void => { @@ -24,6 +28,8 @@ const submit = (): void => { return; } + trackBeginCheckout({ name: props.plan.name, interval: props.plan.interval }); + form.post(checkout.url()); }; diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php index 5f25e6aa..21c00f74 100644 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -263,6 +263,22 @@ function subscribeOnboardingAccount(Account $account): void ); }); +test('connect passes the workspace plan so the client can fire begin_checkout', function () { + $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); + onboardingWorkspace($this->user); + + $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); + + $response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('onboarding/Connect') + ->where('plan.name', $plan->name) + ->where('plan.interval', 'monthly') + ); +}); + test('connect offers a single linkedin card and no standalone linkedin page card', function () { $this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]); onboardingWorkspace($this->user); From 2f4b9741304e292c4902cf64f3ee34382e84f80b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 2 Jul 2026 20:32:55 -0300 Subject: [PATCH 04/14] Fix X token chain breaking from over-rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X OAuth2 refresh tokens are single-use: each refresh rotates the pair and invalidates the previous refresh_token, and reusing a rotated one kills the whole family. Three things made this fragile and disconnected accounts far more often than necessary: - The proactive refresh job called refreshToken() directly, bypassing the access-token-first guard in verify() and rotating on every run. - RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the 2h access-token lifetime — so every X account was rotated every hour even while its token was still valid. - A single 4xx refresh failure disconnected the account without checking whether a concurrent refresh had already persisted a working token. Changes: - RefreshSocialToken now routes through verify() (access-token-first), so it only rotates when the access_token is actually invalid. - Shrink the proactive window to 30m and run the command every 15m, so the window still covers the run interval but rotation happens near real expiry. - verify() tolerates the lost-rotation race: on a 4xx refresh, reload and verify with a concurrently-refreshed token before marking TokenExpired. Refs #126 --- .../Commands/RefreshExpiringTokens.php | 4 +- app/Jobs/RefreshSocialToken.php | 2 +- app/Services/Social/ConnectionVerifier.php | 39 +++++++++++++++---- routes/console.php | 2 +- .../Commands/RefreshExpiringTokensTest.php | 10 ++--- tests/Feature/Jobs/RefreshSocialTokenTest.php | 37 +++++++++++++++--- .../Social/ConnectionVerifierTest.php | 31 +++++++++++++++ 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 200c3063..981fe1b1 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -13,7 +13,7 @@ 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 tokens expiring in the next 30 minutes (or already expired)'; public function handle(): void { @@ -22,7 +22,7 @@ public function handle(): void SocialAccount::query() ->where('status', Status::Connected) ->whereNotNull('token_expires_at') - ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '<=', now()->addMinutes(30)) ->chunk(50, function ($accounts) use (&$count) { foreach ($accounts as $account) { RefreshSocialToken::dispatch($account); diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 25b6b1bf..53df7da8 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -24,7 +24,7 @@ public function __construct(public SocialAccount $account) {} public function handle(ConnectionVerifier $verifier): void { try { - $verifier->refreshToken($this->account); + $verifier->verify($this->account); } catch (PlatformUnavailableException $e) { Log::warning('Token refresh skipped: platform unavailable', [ 'account_id' => $this->account->id, diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 085699b1..4cb29072 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -31,9 +31,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,14 +39,39 @@ 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 the refresh is rejected (4xx) but a concurrent refresh has already + * rotated the account and persisted a fresh access_token, reload and + * verify with that token instead of giving up — X (and other providers + * that single-use their refresh_token) otherwise disconnect a still-usable + * account whenever two refreshes race and one loses the rotation. + * + * @throws TokenExpiredException + * @throws PlatformUnavailableException + */ + private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool + { + $accessTokenBeforeRefresh = $account->access_token; + + try { + $this->refreshToken($account); + } catch (TokenExpiredException $e) { + $account->refresh(); + + if ($account->access_token !== $accessTokenBeforeRefresh) { + return $this->callVerifyEndpoint($account); } - return $this->callVerifyEndpoint($account); + throw $original ?? $e; } + + return $this->callVerifyEndpoint($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..ea8b8019 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -9,25 +9,25 @@ 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 tokens expiring within 30 minutes or already expired', function () { Queue::fake(); $workspace = Workspace::factory()->create(); - // Should be refreshed (expires in 1 hour) + // Should be refreshed (expires in 15 minutes — inside the proactive window) $expiringSoon = SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::LinkedIn, 'status' => Status::Connected, - 'token_expires_at' => now()->addHour(), + 'token_expires_at' => now()->addMinutes(15), ]); - // Should NOT be refreshed (expires in 5 hours — outside the proactive window) + // Should NOT be refreshed (expires in 1 hour — outside the proactive window) SocialAccount::factory()->create([ 'workspace_id' => $workspace->id, 'platform' => Platform::Instagram, 'status' => Status::Connected, - 'token_expires_at' => now()->addHours(5), + 'token_expires_at' => now()->addHour(), ]); // SHOULD be refreshed (already expired — last-chance attempt before the diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index c9a9226d..6299504e 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -11,6 +11,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 +25,46 @@ ]); }); -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('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 +86,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 +104,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..74d1bbe9 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; @@ -414,6 +415,36 @@ 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('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), From 3dd1804c8e80c6ed4e25e28afb356782a8c84f2f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 08:30:58 -0300 Subject: [PATCH 05/14] Stop publishers/analytics from rotating still-valid tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every publisher and analytics service proactively refreshed the token when it was expired OR merely "expiring soon" (within 15 min), calling refreshToken() directly. For X (and other single-use-refresh providers) that rotated a perfectly valid access_token whenever an operation ran in the token's final 15 minutes — the same needless rotation that breaks the refresh_token chain and disconnects accounts. Narrow every pre-check to refresh only when the token is actually expired. A still-valid token is used as-is; if it expires mid-operation the existing reactive retry (PublishToSocialPlatform) refreshes and retries. - Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks. - Remove the now-unused `isTokenExpiringSoon` accessor (no references remain anywhere in the repo). - The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and the expired-token path are unchanged. Refs #126 --- app/Models/SocialAccount.php | 7 ------ .../Social/AbstractLinkedInPublisher.php | 2 +- app/Services/Social/BlueskyPublisher.php | 2 +- app/Services/Social/InstagramAnalytics.php | 4 +-- app/Services/Social/InstagramPublisher.php | 2 +- app/Services/Social/LinkedInPageAnalytics.php | 4 +-- app/Services/Social/PinterestAnalytics.php | 4 +-- app/Services/Social/PinterestPublisher.php | 4 +-- app/Services/Social/ThreadsAnalytics.php | 4 +-- app/Services/Social/ThreadsPublisher.php | 2 +- app/Services/Social/TikTokAnalytics.php | 2 +- app/Services/Social/TikTokCreatorInfo.php | 2 +- app/Services/Social/TikTokPublisher.php | 2 +- app/Services/Social/XAnalytics.php | 4 +-- app/Services/Social/XPublisher.php | 4 +-- app/Services/Social/YouTubeAnalytics.php | 4 +-- app/Services/Social/YouTubePublisher.php | 2 +- .../Services/Social/LinkedInPublisherTest.php | 8 ++++-- .../Services/Social/XPublisherTest.php | 25 +++++++++++++++++++ tests/Feature/Services/XPublisherTest.php | 8 ++++-- 20 files changed, 61 insertions(+), 35 deletions(-) diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..17331459 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,13 +86,6 @@ protected function isTokenExpired(): Attribute ); } - protected function isTokenExpiringSoon(): Attribute - { - return Attribute::make( - get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), - ); - } - protected function avatarUrl(): Attribute { return Attribute::make( diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 5fd6f5e7..527f98be 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($this->account); } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 90a36c6f..01db5938 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 50c20c88..87dd9864 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 23e684d8..ca6eb0f4 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 71fb4355..e20651f7 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 082963a2..6bb04561 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c0669c53..c17114f3 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index ef196477..10a2b2a5 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index fe256fa3..36c08916 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 37861978..3ca14bbf 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 7571d067..1a26ef0c 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 13bf6991..14c8a068 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index c562b2a8..052ffd80 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5a535605..5c7cbec7 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,8 +37,8 @@ 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) { + // Refresh only when the token is actually expired + if ($account->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c4eb8342..c5dd3e69 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->is_token_expired) { 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..667e613b 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->is_token_expired) { app(ConnectionVerifier::class)->refreshToken($account); } 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 () { From 1bb67b7abb3d55c8d4863e7439e4acec09ba5e93 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 09:18:45 -0300 Subject: [PATCH 06/14] Keep Instagram/Threads tokens extended while still valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold review caught a regression from the two previous commits. Instagram and Threads use long-lived tokens refreshed by EXTENDING the access_token itself (grant_type=ig_refresh_token / th_refresh_token) — they have no separate refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule ("only refresh a token once it's actually expired") is right for rotating single-use refresh_token platforms but wrong for these: it left IG/Threads tokens to lapse, after which the extend call fails and the account disconnects (~every 60 days). Gate the anti-rotation on the platform's refresh model: - Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads. - SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms, OR expiring-soon for extension platforms (restores isTokenExpiringSoon). - RefreshSocialToken extends (refreshToken) extension-model tokens while still valid, and verifies (access-token-first) rotating ones. - All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh(). Tests: proactive job extends a still-valid Instagram token; a model test covers the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests are unchanged. Refs #126 --- app/Enums/SocialAccount/Platform.php | 16 +++++++++++ app/Jobs/RefreshSocialToken.php | 9 +++++- app/Models/SocialAccount.php | 20 +++++++++++++ .../Social/AbstractLinkedInPublisher.php | 2 +- app/Services/Social/BlueskyPublisher.php | 2 +- app/Services/Social/InstagramAnalytics.php | 4 +-- app/Services/Social/InstagramPublisher.php | 2 +- app/Services/Social/LinkedInPageAnalytics.php | 4 +-- app/Services/Social/PinterestAnalytics.php | 4 +-- app/Services/Social/PinterestPublisher.php | 4 +-- app/Services/Social/ThreadsAnalytics.php | 4 +-- app/Services/Social/ThreadsPublisher.php | 2 +- app/Services/Social/TikTokAnalytics.php | 2 +- app/Services/Social/TikTokCreatorInfo.php | 2 +- app/Services/Social/TikTokPublisher.php | 2 +- app/Services/Social/XAnalytics.php | 4 +-- app/Services/Social/XPublisher.php | 3 +- app/Services/Social/YouTubeAnalytics.php | 4 +-- app/Services/Social/YouTubePublisher.php | 2 +- tests/Feature/Jobs/RefreshSocialTokenTest.php | 25 +++++++++++++++++ tests/Feature/SocialAccountModelTest.php | 28 +++++++++++++++++++ 21 files changed, 120 insertions(+), 25 deletions(-) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e66e2164..3ca8f5a7 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -261,6 +261,22 @@ 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, + }; + } + public function queue(): string { return 'social-'.$this->value; diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 53df7da8..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->verify($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 17331459..21a3f573 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -86,6 +86,26 @@ protected function isTokenExpired(): Attribute ); } + protected function isTokenExpiringSoon(): Attribute + { + return Attribute::make( + get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)), + ); + } + + /** + * 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 527f98be..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) { + 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 01db5938..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 87dd9864..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index ca6eb0f4..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index e20651f7..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 6bb04561..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index c17114f3..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index 10a2b2a5..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 36c08916..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 3ca14bbf..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 1a26ef0c..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 14c8a068..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 052ffd80..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 5c7cbec7..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 only when the token is actually expired - if ($account->is_token_expired) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index c5dd3e69..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) { + 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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 667e613b..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) { + if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 6299504e..e9e44399 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; @@ -60,6 +61,30 @@ 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('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); 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 () { From 6037169aa9639bfed77cc45275ed7fa3dbfe7b84 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 09:58:26 -0300 Subject: [PATCH 07/14] Cover Threads token extension with an explicit test The extend-while-valid path is shared by Instagram and Threads via Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the Instagram job test so both extension-model platforms are locked down. Refs #126 --- tests/Feature/Jobs/RefreshSocialTokenTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index e9e44399..bf9d0269 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -85,6 +85,28 @@ 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('refresh job marks account as TokenExpired when refresh_token is rejected', function () { Queue::fake(); From 94d0156ec0cfef52f2c01be9575ad6711d516ad8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 10:33:26 -0300 Subject: [PATCH 08/14] Give Instagram/Threads a wider proactive-refresh window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension-model tokens (Instagram/Threads) can't be refreshed once they expire, so the shared 30-minute cron window left only a ~15-minute buffer against queue backlog on the default queue — and a lapse forces a full reconnect. Rotating platforms go through verify() and won't rotate a still-valid token, so they keep the tight 30-minute window; extension platforms now get a 24-hour lead via a per-model query. --- .../Commands/RefreshExpiringTokens.php | 20 ++++- app/Enums/SocialAccount/Platform.php | 16 ++++ .../Commands/RefreshExpiringTokensTest.php | 84 +++++++++++++++---- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 981fe1b1..38b6b689 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 30 minutes (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()->addMinutes(30)) + ->where(function (Builder $query) { + $query->where(function (Builder $extension) { + $extension->whereIn('platform', Platform::extensionModelValues()) + ->where('token_expires_at', '<=', now()->addDay()); + })->orWhere(function (Builder $rotating) { + $rotating->whereNotIn('platform', Platform::extensionModelValues()) + ->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 3ca8f5a7..59e7115a 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -277,6 +277,22 @@ public function extendsAccessTokenOnRefresh(): bool }; } + /** + * All platform values that refresh by extending the access_token itself + * (see extendsAccessTokenOnRefresh). Because these can't be refreshed once + * expired, the proactive-refresh cron gives them a wider window than + * rotating platforms so a queue backlog can't let them lapse. + * + * @return array + */ + public static function extensionModelValues(): array + { + return array_values(array_map( + fn (self $platform): string => $platform->value, + array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()), + )); + } + public function queue(): string { return 'social-'.$this->value; diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index ea8b8019..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 30 minutes 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 15 minutes — inside the proactive window) - $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), ]); - // Should NOT be refreshed (expires in 1 hour — outside the proactive window) + // 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 be refreshed (already expired — last-chance attempt before the - // refresh_token also dies at the provider). - $justExpired = SocialAccount::factory()->create([ + // 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()->addDays(2), + ]); + + // 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 () { From 545a7798487e055922999a23428d05dddabc4f00 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 10:33:26 -0300 Subject: [PATCH 09/14] Never leave Instagram/Threads with a null token expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A null token_expires_at drops an account from every refresh path (the cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon checks all treat null as "nothing to do"), so the token silently lapses. Threads could persist null two ways: the long-lived exchange failing at connect (kept the ~1h short-lived token) — now fails the connect instead; and a refresh response omitting expires_in — now defaults to 60 days for both Instagram and Threads, matching the X refresh convention. --- .../Controllers/Auth/ThreadsController.php | 19 ++--- app/Services/Social/ConnectionVerifier.php | 4 +- .../Social/ConnectionVerifierTest.php | 36 ++++++++++ .../Feature/Social/ThreadsControllerTest.php | 72 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index c8445f7a..f4ea63ff 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -106,15 +106,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' => $longLivedResponse->body(), + ]); + throw new \Exception('Failed to exchange long-lived token'); } + $longLivedData = $longLivedResponse->json(); + $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; + $expiresIn = $longLivedData['expires_in'] ?? 5184000; + // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ 'access_token' => $longLivedToken, @@ -142,7 +145,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/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 4cb29072..60513199 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -322,7 +322,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', 5184000)), ]); $account->refresh(); @@ -341,7 +341,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', 5184000)), ]); $account->refresh(); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 74d1bbe9..e5472b44 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -284,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), 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(); +}); From 4d5ca6b274bbbf290f116f2f3e784b3b5056c600 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:07:54 -0300 Subject: [PATCH 10/14] Centralize the Meta long-lived token TTL and clarify the extension helper The 60-day fallback used when Meta omits expires_in was duplicated as a bare 5184000 across the Instagram/Threads connect and refresh code; it now lives in one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the name states what it returns without needing the extendsAccessTokenOnRefresh docblock. --- app/Console/Commands/RefreshExpiringTokens.php | 4 ++-- app/Enums/SocialAccount/Platform.php | 17 ++++++++++++----- .../Controllers/Auth/InstagramController.php | 2 +- app/Http/Controllers/Auth/ThreadsController.php | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 38b6b689..9dc08b66 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -32,10 +32,10 @@ public function handle(): void ->whereNotNull('token_expires_at') ->where(function (Builder $query) { $query->where(function (Builder $extension) { - $extension->whereIn('platform', Platform::extensionModelValues()) + $extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues()) ->where('token_expires_at', '<=', now()->addDay()); })->orWhere(function (Builder $rotating) { - $rotating->whereNotIn('platform', Platform::extensionModelValues()) + $rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues()) ->where('token_expires_at', '<=', now()->addMinutes(30)); }); }) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 59e7115a..808f865b 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -23,6 +23,13 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; + /** + * Fallback lifetime, in seconds, for Meta long-lived tokens (Instagram and + * Threads — see extendsAccessTokenOnRefresh) when the provider response + * omits expires_in. Meta issues these tokens for 60 days. + */ + public const LONG_LIVED_TOKEN_TTL_SECONDS = 5184000; + /** * The social network this platform belongs to. Variants that represent the * same network (LinkedIn profile vs. company page, Instagram standalone vs. @@ -278,14 +285,14 @@ public function extendsAccessTokenOnRefresh(): bool } /** - * All platform values that refresh by extending the access_token itself - * (see extendsAccessTokenOnRefresh). Because these can't be refreshed once - * expired, the proactive-refresh cron gives them a wider window than - * rotating platforms so a queue backlog can't let them lapse. + * 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 extensionModelValues(): array + public static function accessTokenExtendingPlatformValues(): array { return array_values(array_map( fn (self $platform): string => $platform->value, diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index 79931649..af48218c 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 ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; $tokenExpiresAt = now()->addSeconds($expiresIn); $workspace->socialAccounts()->updateOrCreate( diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index f4ea63ff..6b29e568 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -116,7 +116,7 @@ public function callback(Request $request): InertiaResponse $longLivedData = $longLivedResponse->json(); $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? 5184000; + $expiresIn = $longLivedData['expires_in'] ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ From 83ba72ddc60916d2aa56932a3b47991f7b885a3d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:07:54 -0300 Subject: [PATCH 11/14] Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TokenRefreshClient classified every non-5xx/429 refresh failure as a dead token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as HTTP 4xx with type OAuthException, so a throttled proactive refresh was disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min refresh cadence raised the odds of hitting it. Classification now keys on error code 190 (the signal the publish exceptions already use): only a genuine 190 disconnects; every other Meta 4xx is treated as transient (PlatformUnavailable) and retried next cycle. The same over-broad type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced with the shared Meta\GraphError helper so verify and refresh agree. --- app/Services/Social/ConnectionVerifier.php | 54 +++++++--------- app/Services/Social/Meta/GraphError.php | 30 +++++++++ app/Services/Social/TokenRefreshClient.php | 24 +++++-- tests/Feature/Jobs/RefreshSocialTokenTest.php | 22 +++++++ .../Social/ConnectionVerifierTest.php | 62 +++++++++++++++++++ .../Services/Social/Meta/GraphErrorTest.php | 35 +++++++++++ 6 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 app/Services/Social/Meta/GraphError.php create mode 100644 tests/Unit/Services/Social/Meta/GraphErrorTest.php diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 60513199..b795eed0 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; @@ -311,10 +312,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'); @@ -322,7 +326,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), ]); $account->refresh(); @@ -330,10 +334,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'); @@ -341,7 +348,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), ]); $account->refresh(); @@ -408,13 +415,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(); @@ -429,13 +431,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(); @@ -450,13 +447,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/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/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/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index bf9d0269..898950c3 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -107,6 +107,28 @@ 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(); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index e5472b44..29a38b99 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -561,3 +561,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/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(); +}); From 3d3150bd2008e9e1791198bee58718edd89aeb28 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:26:48 -0300 Subject: [PATCH 12/14] Redact the Threads token-exchange failure logs The connect flow logged the raw response body of a failed token exchange, unlike the TokenRedactor discipline used everywhere else. A failure body carries no token, but redacting keeps it consistent and defensive. --- app/Http/Controllers/Auth/ThreadsController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 6b29e568..059b173e 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'); } @@ -109,7 +110,7 @@ public function callback(Request $request): InertiaResponse if ($longLivedResponse->failed()) { Log::error('Threads long-lived token exchange failed', [ 'status' => $longLivedResponse->status(), - 'body' => $longLivedResponse->body(), + 'body' => TokenRedactor::redact($longLivedResponse->body()), ]); throw new \Exception('Failed to exchange long-lived token'); } From ba8068f517d947345d8a4034c814835cfbf6b5cf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:39:48 -0300 Subject: [PATCH 13/14] Recover from a lost-rotation race on the post-refresh verify too refreshThenVerify only guarded refreshToken(); the verify that followed was outside the try, so the sub-commit window where a lock-skipped refresh reloads a not-yet-persisted token surfaced as a 401 there and falsely disconnected a still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the verify that follows it now share one recovery: on a TokenExpiredException, reload and, if a concurrent refresh has since persisted a fresh access_token, verify with it instead of giving up. --- app/Services/Social/ConnectionVerifier.php | 16 ++++++----- .../Social/ConnectionVerifierTest.php | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index b795eed0..547b78d6 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -47,11 +47,13 @@ public function verify(SocialAccount $account): bool /** * Refresh the token, then verify with the new one. * - * If the refresh is rejected (4xx) but a concurrent refresh has already - * rotated the account and persisted a fresh access_token, reload and - * verify with that token instead of giving up — X (and other providers - * that single-use their refresh_token) otherwise disconnect a still-usable - * account whenever two refreshes race and one loses the rotation. + * 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 @@ -62,6 +64,8 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio try { $this->refreshToken($account); + + return $this->callVerifyEndpoint($account); } catch (TokenExpiredException $e) { $account->refresh(); @@ -71,8 +75,6 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio throw $original ?? $e; } - - return $this->callVerifyEndpoint($account); } /** diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 29a38b99..9d523ec5 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -481,6 +481,33 @@ && $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), From c8f875550a5145023f2074d53e27f254d44b02ce Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 13:11:36 -0300 Subject: [PATCH 14/14] Replace generic token-TTL constant with per-platform Platform::defaultTokenTtlSeconds() The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose inline 7200 for X made it unclear which networks each value applied to. Express the fallback TTL as a per-platform match method instead, matching how the enum already exposes every other per-network value, so the network->value mapping is visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they always return expires_in). Behavior is unchanged. --- app/Enums/SocialAccount/Platform.php | 29 ++++++++++++++----- .../Controllers/Auth/InstagramController.php | 2 +- .../Controllers/Auth/ThreadsController.php | 2 +- app/Services/Social/ConnectionVerifier.php | 6 ++-- tests/Unit/Enums/PlatformTest.php | 18 ++++++++++++ 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 808f865b..afcd02f3 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -23,13 +23,6 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; - /** - * Fallback lifetime, in seconds, for Meta long-lived tokens (Instagram and - * Threads — see extendsAccessTokenOnRefresh) when the provider response - * omits expires_in. Meta issues these tokens for 60 days. - */ - public const LONG_LIVED_TOKEN_TTL_SECONDS = 5184000; - /** * The social network this platform belongs to. Variants that represent the * same network (LinkedIn profile vs. company page, Instagram standalone vs. @@ -300,6 +293,28 @@ public static function accessTokenExtendingPlatformValues(): array )); } + /** + * 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 af48218c..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 ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_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 059b173e..955009ea 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -117,7 +117,7 @@ public function callback(Request $request): InertiaResponse $longLivedData = $longLivedResponse->json(); $longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken; - $expiresIn = $longLivedData['expires_in'] ?? SocialPlatform::LONG_LIVED_TOKEN_TTL_SECONDS; + $expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds(); // Fetch user profile $profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [ diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 547b78d6..071d554e 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -181,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(); @@ -328,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); @@ -350,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void $account->update([ 'access_token' => $newToken, 'refresh_token' => $newToken, - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', Platform::LONG_LIVED_TOKEN_TTL_SECONDS)), + 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())), ]); $account->refresh(); 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();