trypost/tests/Feature/Jobs/RefreshSocialTokenTest.php
Paulo Castellano 2f4b974130 Fix X token chain breaking from over-rotation
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
2026-07-02 20:32:55 -03:00

116 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Jobs\RefreshSocialToken;
use App\Jobs\SendNotification;
use App\Models\SocialAccount;
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;
beforeEach(function () {
$this->owner = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
$this->account = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
'status' => Status::Connected,
'username' => 'testuser',
]);
});
test('refresh job routes through verify (access-token-first) not refreshToken', function () {
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->with(
Mockery::on(fn ($account) => $account->id === $this->account->id)
);
$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('verify')->once()->andThrow(
new TokenExpiredException('refresh_token revoked')
);
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
expect($this->account->fresh()->status)->toBe(Status::TokenExpired);
expect($this->account->fresh()->error_message)->toBe('refresh_token revoked');
// Notification dispatched because account transitioned from Connected.
Queue::assertPushed(SendNotification::class);
});
test('refresh job logs warning on non-token errors and leaves status alone', function () {
Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) {
return $message === 'Proactive token refresh failed'
&& $context['account_id'] === $this->account->id
&& $context['error'] === 'network blip';
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new RuntimeException('network blip'));
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
expect($this->account->fresh()->status)->toBe(Status::Connected);
});
test('refresh job does NOT mark account expired when platform is unavailable', function () {
Queue::fake();
Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) {
return $message === 'Token refresh skipped: platform unavailable'
&& $context['account_id'] === $this->account->id
&& str_contains($context['error'], '503');
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
);
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
expect($this->account->fresh()->status)->toBe(Status::Connected);
Queue::assertNotPushed(SendNotification::class);
});