Keep Instagram/Threads tokens extended while still valid
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
This commit is contained in:
parent
3dd1804c8e
commit
1bb67b7abb
21 changed files with 120 additions and 25 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue