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(); +});