* Stop paying X for token checks the refresh already proves
RefreshSocialToken verified rotating-refresh platforms instead of
refreshing them. On a still-valid token verify() only called the platform's
verify endpoint and left token_expires_at untouched, so the account stayed
inside RefreshExpiringTokens' 30-minute window and was re-read every 15
minutes until the token actually died.
On X that endpoint is GET /2/users/me, billed as a "User: Read" ($0.010 per
resource under X's pay-per-usage pricing). Simulating 24h of the scheduler
against one connected X account: 36 billed reads per day, 24 of which
renewed nothing, plus 180 minutes per day sitting on an expired token
between expiry and the next tick.
Refresh the token outright instead. A provider that hands back a fresh token
has already confirmed the credential — it rejects a revoked one with a 4xx,
which TokenRefreshClient maps to TokenExpiredException — so the verify call
adds cost and nothing else. Record the confirmation in last_verified_at, and
let the daily sweep trust it for 12 hours the way VerifyUpcomingPostConnections
already does, so it stops re-reading accounts a refresh just proved valid.
Same simulation after the change: 0 billed reads, 0 minutes expired.
Two existing tests asserted the old policy (verify-first, refresh_token left
unrotated) and now assert the new one. The rotation test still guards what
made that policy attractive: a proactive rotation must not trip a
false-positive disconnect.
* Don't disconnect an account whose access token still works
Refreshing instead of verifying removed a safety net the old verify-first
path had: if the refresh is rejected, the account was marked TokenExpired
outright. But a rejected refresh does not mean the connection is dead.
X and LinkedIn single-use their refresh_token, so a token a concurrent
refresh already consumed comes back 4xx while the current access_token keeps
working. An account with no refresh_token at all fails even earlier, without
a single call being made. Verified against main: both cases stayed Connected
before, and became TokenExpired after. PublishToSocialPlatform hard-fails
every post for a TokenExpired account, so this killed posts the access_token
would have published, up to 30 minutes before the token was actually due to
expire — and emailed the owner a disconnect notice for it.
Fall back to verifying the access token before disconnecting. This is the
only path in the job that reaches the billed verify endpoint, and only after
a refresh has already been rejected, so the healthy path stays at zero reads
(re-confirmed: 0 reads and 0 expired minutes across a simulated 24h). A
failure that can't be attributed to the token — platform down, network blip —
leaves the account alone instead of disconnecting it on noise.
Also covers two gaps found while reviewing: a lock-skipped refresh must not
record a verification it never performed, and a platform with nothing to
refresh must not be recorded as verified either. Both already behaved
correctly; they now have tests so the daily sweep can't start trusting a
stamp nobody earned.
* Only record a verification something actually proved
Review of the previous two commits turned up four issues, all in code they
introduced.
The stamp lived inside ConnectionVerifier::refreshToken(), which
refreshThenVerify() also calls — there the refresh can succeed and the verify
that follows still fail. The stamp was already written by then, vouching for a
credential nothing confirmed. Normally harmless, because the caller marks the
account TokenExpired and recentlyProvenValid() only skips Connected ones, but
markAsTokenExpired() silently no-ops when its status lock is held by a
concurrent publish. The account then stays Connected with a fresh stamp, and
both skip-windows wave it through: 40 minutes before publishing, 12 hours in
the daily sweep. Move the stamp to the caller that owns the outcome.
TokenRefreshClient classifies on HTTP status alone and never inspects the
body, so a 200 carrying an empty access_token is stored as-is and was then
recorded as healthy. (A missing key rather than an empty one can't get that
far — access_token is NOT NULL, so the write throws first.) Guard on a filled
token.
The fallback added in 9775578 called verify(), which for an already-expired
account — RefreshExpiringTokens selects those too — runs refreshThenVerify()
and re-sends the refresh_token the provider just rejected, and on Bluesky
re-runs the password re-auth AT Proto rate-limits per account. Its own
docblock claimed it only reached the verify endpoint. Add
verifyAccessToken(), which checks the stored token and nothing else.
VerifyWorkspaceConnections read last_verified_at without ever writing it, so
an account it had just confirmed healthy still burned a fresh call minutes
later when a post entered the risk window. Stamp on its success path too.
Also corrects the RefreshExpiringTokens docblock, which still described the
verify-first behaviour removed in c0d0d57.
Re-ran the 24h whole-scheduler simulation: still 0 billed reads, 0 expired
minutes, account Connected at the end.
* Fall back to the token a concurrent refresh persisted
The fallback added in 9775578 judged the in-memory access_token, which is
exactly the one that is stale when a refresh loses a race.
X single-uses the refresh_token, so when two refreshes overlap the loser's
token comes back 400 invalid_grant. Its in-memory instance still holds the
pair the winner has already rotated away, so verifying it 401s and the
account is marked TokenExpired — while the row in the database holds a
perfectly healthy token the winner just wrote.
Verified against main: a concurrent rotation leaves the account Connected
there and TokenExpired here. refreshThenVerify() already handles this by
reloading and retrying with whatever was persisted; the new path skipped that
because it never went through refreshThenVerify.
Reload before judging. This is the failure path only, so the healthy path is
untouched — the whole-scheduler simulation still reports 0 billed reads, 0
expired minutes, Connected at the end.
* Don't record a verification when the lock skipped the refresh
refreshToken() returns normally — no exception — when another process already
holds the per-account lock, so the caller can't tell "refreshed" from "did
nothing". Moving the stamp out of the verifier in 4d2795a lost that
distinction: RefreshSocialToken stamped last_verified_at on a run that made
zero HTTP calls.
The account is then vouched for by nobody: the daily sweep skips it for 12
hours and the pre-publish check for 40 minutes. If the concurrent refresh
also failed, nothing ever confirmed the credential.
Have refreshToken() report whether it actually ran. Callers that ignore the
return value are unaffected.
The test meant to cover this called refreshToken() directly rather than going
through the job, so it kept passing while the job path was broken — it now
exercises the job and asserts no HTTP call was made.
* Close the two concurrency gaps the refresh path leaves open
Both are pre-existing, but this branch raises the exposure to them from 12 to
16 refreshes per account per day.
The per-account lock lasted 30 seconds, exactly the HTTP client's default read
timeout — so a refresh could outlive the lock that protects it. Bluesky is the
worst case, refreshing with two sequential calls (refreshSession, then the
createSession re-auth), each bounded by connect + read timeouts: up to ~80
seconds under one 30-second lock. Once it lapses, a second process refreshes
with the same single-use refresh_token and one of the two is rejected. Name
the TTL, set it past the ceiling, and write down the invariant so a future
slower refresh doesn't quietly break it.
RefreshSocialToken was not unique. RefreshExpiringTokens re-selects an account
until token_expires_at moves, and that only happens once the job runs — so a
queue more than one tick behind stacked a job per tick for the same account,
each rotating a single-use refresh_token again for nothing and widening the
gap where a worker death loses the pair. Key it by account like
VerifyUpcomingPostConnections already does.
Cadence is unchanged: the whole-scheduler simulation still reports 16
refreshes, 0 billed reads and 0 expired minutes over 24h.
* Correct which providers actually single-use their refresh_token
Checked each provider's official documentation rather than carrying the
assumption forward.
LinkedIn does not rotate. Its refresh docs are explicit: "the lifespan or Time
To Live (TTL) of the refresh token remains the same as specified in the
initial OAuth flow (365 days)" — the same token comes back with a decreasing
refresh_token_expires_in, and only the access token is reissued. The claim
that it single-uses the token predates this branch, but a docblock added here
repeated it.
Bluesky does rotate, and belongs in the list instead: com.atproto.server
.refreshSession declares refreshJwt as a required output field, so every
refresh mints a new one.
Verified alongside, all matching what the code already does:
X access token 2h, refresh single-use with rotation
Bluesky refreshJwt rotates; createSession is rate-limited per handle
(30/5min, 300/day), which the fallback re-auth path shares
TikTok access 24h, refresh 365d, "may be different — you must use the
newly-returned token", which refreshTikTokToken does
LinkedIn access 60d, refresh 365d fixed, not rotated
Google does not rotate on refresh; 100 refresh tokens per account per
client, so the higher refresh rate on YouTube carries no
rotation risk
* Read X post metrics from the timeline that already returned them
Analytics fetched the account's timeline for post ids, then turned around and
looked the same ids up again through GET /2/tweets purely to read the
public_metrics the first request could have returned. The timeline call asked
for start_time, end_time and max_results — never tweet.fields.
Both endpoints bill per Post returned, so the second pass claimed the same
resources a second time, took a second round-trip, and spent a second slice of
the same rate limit. For an account with 250 posts in range that is 6 requests
where 3 will do.
The saving is in round-trips and rate limit rather than dollars: X deduplicates
a resource within a 24-hour UTC window, so the second read of an id already
read that day is not charged again. But the docs call that a soft guarantee
that "may result in resources not being deduplicated" — this stops leaning on
it for 250 resources per analytics load.
Behaviour is unchanged: same totals, same 5-page ceiling, same empty result
when the account posted nothing in range. The page cap is now a named constant,
since it bounds what one load can cost as much as how long it takes.
Adds the first tests for XAnalytics::getMetrics, covering the totals, the
pagination, and that the metrics arrive on the timeline request.
* Cover the analytics paths a happy-path test walks straight past
Three gaps in what the previous commit's tests actually assert:
A timeline page failing mid-pagination breaks out of the loop and returns
whatever was collected. Nothing pinned that — partial data beats an exception
on a dashboard someone is looking at, and a future refactor could quietly turn
it into one.
A post can come back without public_metrics. The accumulator defaults each
metric to 0, so it contributes nothing instead of erroring, which also wasn't
covered.
An account with no posts in range returns [] rather than a list of zeros, so
the UI can tell "nothing posted" from "posted, no engagement".
Also makes the routing test's mock return explicit. Without andReturn, Mockery
hands back a falsy default for the new bool return type, so the assertion about
routing was passing while silently exercising the lock-skipped branch. The test
still asserts only what its name claims, but no longer depends on a mock
default to get there.
* Close five issues an independent review found in this branch
All five sit in code these commits introduced.
Instagram and Threads must fail loudly. Their long-lived token is extended in
place and cannot be renewed once it expires, and RefreshExpiringTokens picks
them up a full day ahead precisely so there is time to react. The fallback
added in 9775578 applied to them too, so a permanently rejected extension on a
token that still reads left the account Connected — the daily sweep passed as
well, since verify() succeeds on it — and the owner learned about it only after
the token died unrecoverably, while every tick retried the rejection for 24
hours. The fallback now applies only to platforms that rotate a refresh_token,
which is what it was written for.
The lock went the wrong way. Lengthening it to 120s in 5001c9a treated the
scheduler as the only caller, but publishers wait on the same lock: one left
behind by a worker that died mid-refresh makes refreshToken() return false, and
the publisher falls through and publishes with an expired token. That window
was 30s and had become 120s. Bound the calls instead — a token endpoint answers
in milliseconds, and 8s read / 4s connect keeps even Bluesky's two sequential
calls under a 30-second lock, back to where main had it.
Reloading the account can throw. $this->account->refresh() sat outside the try
in the fallback, and an exception raised inside a catch block is not caught by
a sibling catch. With tries = 1, an account deleted mid-run put the job in
failed_jobs. VerifyUpcomingPostConnections guards this same race explicitly.
An empty access token was detected but not acted on. recordVerification()
declined to stamp it, yet the refresh still counted as a success — and the
refresh method had already pushed token_expires_at two hours out, so the
account left the window looking healthy while every publish 401d. Mark it
expired, which is what verify() used to do on the same input.
refreshToken() claimed "whether a refresh actually ran" but returned true for
platforms whose match arm does nothing. Only recordVerification() re-checking
hasTokenRefreshFlow() kept that from mattering. The guard is now explicit and
the contract true at the source.
Also settles the tweet.fields question against the live API rather than the
docs, which contradict each other: the OpenAPI spec names the parameter
post.fields, while the Fields guide and every example use tweet.fields. Both
are accepted and both return public_metrics. An unrecognised name returns 200
and silently omits the field — no error — so the name being right is load
bearing, and it is.
* Guard the match that no longer has a default arm
Dropping `default => null` from refreshToken() in af379af made the return value
honest, but it also turned a missing case into an UnhandledMatchError at
runtime. The arms and hasTokenRefreshFlow() currently agree on the same nine
platforms, and nothing enforces that: adding a platform to the predicate
without an arm would fail in production, on a queue worker, for one platform's
accounts only.
The test walks every platform claiming a refresh flow and fails by name if the
match has no arm for it. Verified it catches the real thing by temporarily
adding Facebook to the predicate — it failed with "facebook claims a refresh
flow but refreshToken() has no arm for it" — rather than trusting a green run
on code that already agrees with itself.
* Make the per-platform guard fail on a broken client chain
The test added in the previous commit swallowed every Throwable except
UnhandledMatchError, so it proved a match arm existed and nothing more. Routing
all nine refresh methods through refreshHttp() in af379af rewrote how each one
builds its request, and this test would have passed just the same if one of
those chains no longer worked.
It now fails on any exception, naming the platform, and asserts each refresh
actually put a request on the wire — a chain that breaks during construction
raises before anything is sent, so an empty recording is the signal.
Verified by breaking Pinterest's chain on purpose: "pinterest refresh threw
BadMethodCallException: Method PendingRequest::withHeadersTypo does not exist."
All nine send their request with the chains as they stand.
* Stop trading a recoverable failure for an unrecoverable one
A max-effort review found six issues, three of which undo a trade the previous
round got backwards.
Every refresh method wrote the response straight over the stored credential:
`'access_token' => data_get($data, 'access_token')`. A 200 carrying no token
therefore destroyed a working one — and on Instagram and Threads, where
refresh_token is set to the same value, both halves at once. The blank() check
added in af379af only noticed after the damage was persisted, then marked the
account TokenExpired, which RefreshExpiringTokens no longer selects — so one
glitchy-but-successful response emailed the owner and forced a manual
reconnect. Guard before the write instead, treat it as the platform
misbehaving, and the stored pair survives for the next tick to retry. The
detection branch downstream is now unreachable and gone.
Tightening the refresh timeout to 8s was the wrong fix for the lock problem.
refreshHttp() is shared with 24 publish and analytics call sites, and for X,
Bluesky and TikTok the refresh_token is single-use: abandoning a request the
provider has already processed loses the rotated pair permanently and costs the
user a reconnect. Giving up sooner makes that more likely, not less. Restore
generous timeouts and put the lock back above them. The cost of erring long is
that a worker dying mid-refresh holds the lock while a publish falls through
and retries — recoverable, unlike a lost rotation. Both constants now say which
way they are wrong on purpose.
The hasTokenRefreshFlow() guard in recordVerification() was dead: refreshToken()
already returns false for those platforms, so the branch was never entered. The
test claiming to cover it calls refreshToken() directly and never reaches it.
The command reported "Dispatched N" for a number it cannot know. dispatch()
returns a PendingDispatch whether or not ShouldBeUnique discarded it, so the
count overstated itself during exactly the backlog someone reads that line to
diagnose. It now reports accounts in the window, which is what it actually
measured.
Not changed: the daily sweep still skips accounts a refresh keeps fresh. That
is the deliberate decision this PR is built on — a refresh replaces the access
token rather than inspecting it, so there is nothing left for a billed read to
confirm.
Re-verified live after the changes: the real job still rotates the token
against api.x.com and leaves the account connected.
* Cover the tokenless-200 guard on every platform, not just X
The guard added in the previous commit protects nine refresh methods; only X
had a test. Each provider reads a different field name out of the response, so
a regression would land on one platform at a time and the suite would stay
green for the other eight.
The test drives every platform claiming a refresh flow through a 200 that
carries no token, and requires each to refuse with PlatformUnavailableException
— nothing is provably dead, so the next tick should retry rather than anyone
being disconnected — while leaving the stored credential untouched.
Verified it fails usefully by dropping the guard from one platform: 'threads
should refuse a tokenless 200 cleanly, got QueryException: null value in column
access_token violates not-null constraint'. Without naming the platform the
failure reads as an unrelated database error, since the write also poisons the
surrounding transaction.
* Stop the fallback from reading every failure as good news
A fifth review found the concurrent-refresh test passing without ever
exercising what it claims to cover, and the reason it could is a real bug.
access_token is an encrypted cast. The test wrote the winner's pair with
DB::table()->update(), which stores plaintext, so reading it back raised
DecryptException — and accessTokenStillWorks() caught Throwable and returned
true. Green test, zero coverage of the recovery that justifies the method
existing. It now writes through the model and asserts the verify call actually
happened; removing the reload makes it fail.
The catch is the bug. Treating any non-TokenExpiredException as "the token is
healthy" means an APP_KEY rotation, a corrupted column, or an UnhandledMatchError
from a newly added platform leaves the account Connected forever while every
publish hard-fails, and nobody is told. It now names the outcomes that earn the
benefit of the doubt — platform down, network dropped, account deleted mid-run
— and lets the rest surface. Loud is right here: an APP_KEY rotation breaks
every account at once, so failing the job where an operator sees it beats
disconnecting every user.
Refusing to persist a tokenless 200 also had no way out. The account kept
retrying every 15 minutes forever, and the daily sweep counts
PlatformUnavailableException as verified, so it was never disconnected and
never reported. Now the retry only continues while there is a live token
behind it: once that expires and renewal still fails, the connection is dead
in practice and says so.
Also corrects the refreshHttp() docblock, which claimed a blast radius the
private method does not have — refreshToken() is what those 24 call sites
reach — and records in VerifyWorkspaceConnections that short-TTL platforms
never being re-verified is the intended consequence, not an oversight.
* Stop a bad hour at the provider from disconnecting anyone
The escalation added last round was wrong, and two neighbouring paths had the
same shape of bug.
Marking the account expired whenever a PlatformUnavailableException hit an
already-expired token looked like it closed a silent-rot gap. But that
exception is what TokenRefreshClient raises for 5xx, 429 and connection
timeouts — so X rate-limiting for forty minutes around a two-hour token's
expiry disconnected the account, emailed the owner, and hard-failed every
scheduled post, with only the daily sweep to undo it. The rot it was meant to
prevent surfaces at publish time anyway. Reverted: a transient failure never
disconnects.
refresh_token was left unguarded when access_token was hardened. data_get()
only falls back when a key is absent, so a provider answering with an explicit
"refresh_token": null wiped the stored one — and the next tick then threw "no
refresh token available" without making a single call. Guarded in the same four
places, falling back on blank rather than on missing.
A held lock reported "nothing refreshed" even when the token was already dead,
handing the caller a credential it knew was expired. The publisher posts with
it, takes a 401, and PublishToSocialPlatform finalises the post as failed and
disconnects the account — over a lock a dying worker left behind, for the two
minutes it survives. It now says transient, which is what a refresh someone
else is already running actually is.
VerifyWorkspaceConnections promoted TokenExpired accounts back to Connected on
verifyAccount()'s return value, which is also true for "could not check, don't
disconnect". An unreachable platform therefore told owners their reconnect had
worked when nothing was verified. Promotion moved next to the successful
verify.
Each of the four is pinned by a test, and each test was checked by reverting
the fix and confirming it fails.
* Keep a lock collision off the analytics page
Round seven found one real regression from round six, one consistency gap, and
one stale comment.
Reporting lock contention as transient was right for the publish path, which
reschedules, but analytics calls refreshToken() bare and AnalyticsController
has no try/catch — and there is no renderable handler for
PlatformUnavailableException. A user opening analytics for an account whose
token expired while the scheduled job held the lock got an HTTP 500 where the
same request previously returned empty metrics. Reproduced before fixing:
"Expected response status code [200] but received 500". The controller now
degrades to empty numbers and still reports, which also covers the same 500
for any platform whose refresh 5xx'd — possible before this branch too.
The fallback verify was throwing away a result worth keeping. It is a billed
call on X and it proves the token alive exactly as a refresh does, so the
pre-publish check was paying to ask the same question minutes later. Stamped
like the other two sites.
Also rewrites a comment in rotatedTokenFrom() that described the data_get()
call it replaced rather than the blank() check beneath it. The review also
reported a false @throws on that method; it has no docblock at all.
Both fixes checked by reverting them and confirming the new tests fail.
* Degrade analytics on an unreachable platform, not on a bug
The rescue() added last commit caught Throwable, so it did not just absorb a
platform being down — it absorbed everything. A TypeError in any metrics
service rendered as "this account has no activity", with a log line as the only
sign anything was wrong. Reproduced: a metrics service throwing RuntimeException
returned 200 with empty metrics.
This is the same mistake the review flagged two rounds ago in
accessTokenStillWorks(), where treating any exception as "the token is healthy"
hid real failures. Narrowed the same way: PlatformUnavailableException and
ConnectionException degrade to empty numbers and still report, everything else
surfaces as the 500 it is.
The match moved into a named method so the intent has somewhere to live, since
the reason for the narrow catch matters more than the catch itself.
Both directions are pinned: widening the catch back to Throwable fails the
bug-is-not-hidden test, and removing the degradation fails the lock-collision
test.
* Trim the commentary back to what the code cannot say
RefreshSocialToken had 72 comment lines against 95 of code — 43% of the file.
The rest of the branch was heading the same way: two constants in
ConnectionVerifier carried twelve- and eight-line docblocks, and
VERIFIED_WITHIN_HOURS had twelve lines explaining a number.
Most of it was history rather than reasoning: what the code used to do, which
review round asked for a change, the full argument for a decision the code
already states. Kept the parts a reader cannot recover — why a catch is narrow,
why a stamp is not written inside refreshToken(), why the lock has to outlast
the timeouts — and cut the rest.
shouldTrustAWorkingAccessToken() went with it: a one-line method behind a
twelve-line docblock, now the condition it wrapped, inline where it is used.
No behaviour change; full suite unchanged at 3786.
751 lines
30 KiB
PHP
751 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
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\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
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 refreshToken, never the billed verify endpoint', function () {
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->with(
|
|
Mockery::on(fn ($account) => $account->id === $this->account->id)
|
|
)->andReturnTrue();
|
|
$verifier->shouldNotReceive('verify');
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
});
|
|
|
|
test('proactive refresh rotates the X refresh token without disconnecting the account', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => 'rotated-access-token',
|
|
'refresh_token' => 'rotated-refresh-token',
|
|
'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));
|
|
|
|
// X single-uses the refresh_token, so rotating one proactively has to leave
|
|
// the account healthy instead of tripping a false-positive disconnect.
|
|
expect($this->account->fresh()->refresh_token)->toBe('rotated-refresh-token');
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
});
|
|
|
|
test('proactive refresh EXTENDS a still-valid Instagram token (extension-model platform)', function () {
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform' => Platform::Instagram,
|
|
'status' => Status::Connected,
|
|
'access_token' => 'old-ig-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
|
'access_token' => 'extended-ig-token',
|
|
'expires_in' => 5184000,
|
|
], 200),
|
|
]);
|
|
|
|
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Instagram/Threads extend the token itself and can't refresh once expired,
|
|
// so a still-valid token IS extended proactively — unlike rotating platforms.
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
|
expect($account->fresh()->access_token)->toBe('extended-ig-token');
|
|
});
|
|
|
|
test('proactive refresh EXTENDS a still-valid Threads token (extension-model platform)', function () {
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform' => Platform::Threads,
|
|
'status' => Status::Connected,
|
|
'access_token' => 'old-threads-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
|
|
'access_token' => 'extended-threads-token',
|
|
'expires_in' => 5184000,
|
|
], 200),
|
|
]);
|
|
|
|
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
|
expect($account->fresh()->access_token)->toBe('extended-threads-token');
|
|
});
|
|
|
|
test('proactive refresh does NOT disconnect Instagram on a Meta rate-limit (400 OAuthException code 4)', function () {
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform' => Platform::Instagram,
|
|
'status' => Status::Connected,
|
|
'access_token' => 'valid-ig-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
|
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
|
|
], 400),
|
|
]);
|
|
|
|
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// A rate-limit is transient — the still-valid token must stay Connected.
|
|
expect($account->fresh()->status)->toBe(Status::Connected);
|
|
expect($account->fresh()->access_token)->toBe('valid-ig-token');
|
|
});
|
|
|
|
test('refresh job marks account as TokenExpired when refresh_token is rejected', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
|
new TokenExpiredException('refresh_token revoked')
|
|
);
|
|
// The access_token is dead too, so there is nothing left to fall back to.
|
|
$verifier->shouldReceive('verifyAccessToken')->once()->andThrow(
|
|
new TokenExpiredException('X access token is invalid or expired')
|
|
);
|
|
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('refreshToken')->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('refreshToken')->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);
|
|
});
|
|
|
|
test('proactive refresh renews a still-valid X token without spending a billed user read', 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' => 'rotated-access-token',
|
|
'refresh_token' => 'rotated-refresh-token',
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'original-access-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// GET /2/users/me is a billed "User: Read" ($0.010). A successful token
|
|
// refresh already proves the credential works, so it must not be called.
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/users/me'));
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
|
|
|
expect($this->account->fresh()->access_token)->toBe('rotated-access-token');
|
|
expect($this->account->fresh()->token_expires_at->isAfter(now()->addHour()))->toBeTrue();
|
|
});
|
|
|
|
test('a successful refresh stamps last_verified_at so other jobs can skip verifying', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => 'rotated-access-token',
|
|
'refresh_token' => 'rotated-refresh-token',
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
'last_verified_at' => null,
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
expect($this->account->fresh()->last_verified_at)->not->toBeNull();
|
|
});
|
|
|
|
test('a rejected refresh does not disconnect an account whose access token still works', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
// X single-uses the refresh_token; a concurrent refresh already burned
|
|
// this one, so the provider rejects it — but the access_token is alive.
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'error' => 'invalid_grant',
|
|
'error_description' => 'Value passed for the token was invalid.',
|
|
], 400),
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'still-valid-access-token',
|
|
'refresh_token' => 'already-consumed-by-a-race',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// PublishToSocialPlatform hard-fails posts for a TokenExpired account, so
|
|
// disconnecting here would kill posts the access_token could still publish.
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('an account with no refresh token stays connected while its access token works', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'still-valid-access-token',
|
|
'refresh_token' => null,
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('a rejected refresh DOES disconnect once the access token is dead too', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'error' => 'invalid_grant',
|
|
'error_description' => 'refresh_token revoked',
|
|
], 400),
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response([
|
|
'title' => 'Unauthorized',
|
|
'status' => 401,
|
|
], 401),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'dead-access-token',
|
|
'refresh_token' => 'revoked-refresh-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::TokenExpired);
|
|
});
|
|
|
|
test('lock skipped by a concurrent refresh does not record a verification', function () {
|
|
Http::fake([config('trypost.platforms.x.api').'/*' => Http::response([], 200)]);
|
|
|
|
$this->account->update([
|
|
'last_verified_at' => null,
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
Cache::lock("token_refresh:{$this->account->id}", 30)->get();
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Nothing was refreshed here, so nothing was proven — stamping would let
|
|
// the daily sweep skip an account no one actually checked.
|
|
Http::assertNothingSent();
|
|
expect($this->account->fresh()->last_verified_at)->toBeNull();
|
|
});
|
|
|
|
test('a platform with nothing to refresh is never recorded as verified', function () {
|
|
$account = SocialAccount::factory()->mastodon()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'status' => Status::Connected,
|
|
'last_verified_at' => null,
|
|
]);
|
|
|
|
app(ConnectionVerifier::class)->refreshToken($account);
|
|
|
|
expect($account->fresh()->last_verified_at)->toBeNull();
|
|
});
|
|
|
|
test('a refresh whose follow-up verify fails is not recorded as a verification', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => 'fresh-but-rejected',
|
|
'refresh_token' => 'rt-new',
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response(['title' => 'Unauthorized'], 401),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'token_expires_at' => now()->subMinute(),
|
|
'last_verified_at' => null,
|
|
]);
|
|
|
|
try {
|
|
app(ConnectionVerifier::class)->verify($this->account);
|
|
} catch (TokenExpiredException) {
|
|
// expected — the refreshed token is rejected too
|
|
}
|
|
|
|
// The refresh succeeded but the credential was never proven good. Stamping
|
|
// here lets both skip-windows wave through an account nobody verified.
|
|
expect($this->account->fresh()->last_verified_at)->toBeNull();
|
|
});
|
|
|
|
test('a refresh that returns an empty access token is not recorded as a verification', function () {
|
|
// TokenRefreshClient classifies on HTTP status alone and never inspects the
|
|
// body, so a 200 carrying an empty token is stored as-is.
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => '',
|
|
'refresh_token' => 'rt-new',
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
'last_verified_at' => null,
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
expect($this->account->fresh()->last_verified_at)->toBeNull();
|
|
});
|
|
|
|
test('a rejected refresh is not re-sent before the access token is checked', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'error' => 'invalid_grant',
|
|
], 400),
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'still-valid-access-token',
|
|
'refresh_token' => 'already-consumed-by-a-race',
|
|
'token_expires_at' => now()->subMinute(),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Going through verify() would re-send the refresh_token the provider just
|
|
// rejected — and on Bluesky re-run the rate-limited password re-auth.
|
|
$refreshCalls = collect(Http::recorded())
|
|
->filter(fn ($pair) => str_contains($pair[0]->url(), '/oauth2/token'))
|
|
->count();
|
|
|
|
expect($refreshCalls)->toBe(1);
|
|
});
|
|
|
|
test('a refresh lost to a concurrent one falls back to the token that won', function () {
|
|
Queue::fake();
|
|
|
|
$api = config('trypost.platforms.x.api');
|
|
Http::fake([
|
|
// Our refresh_token was already consumed by the process that won.
|
|
$api.'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
|
$api.'/users/me' => function ($request) {
|
|
$auth = $request->header('Authorization')[0] ?? '';
|
|
|
|
return str_contains($auth, 'winner-access-token')
|
|
? Http::response(['data' => ['id' => '123']], 200)
|
|
: Http::response(['title' => 'Unauthorized', 'status' => 401], 401);
|
|
},
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'stale-access-token',
|
|
'refresh_token' => 'stale-refresh-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
// The winner persisted its new pair while ours was in flight; this
|
|
// instance still holds the rotated-away one. Written through a separate
|
|
// model so the encrypted casts apply — a raw DB write stores plaintext,
|
|
// and reading it back throws DecryptException instead of exercising this.
|
|
SocialAccount::find($this->account->id)->update([
|
|
'access_token' => 'winner-access-token',
|
|
'refresh_token' => 'winner-refresh-token',
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// The recovery is the point: reload, find the winner's token, verify with
|
|
// it. Asserting the call proves we got that far rather than bailing early.
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me'));
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('the refresh lock outlives the slowest refresh a provider can make us wait', function () {
|
|
// Bluesky refreshes with two sequential calls (refreshSession, then the
|
|
// createSession re-auth). If the lock expires first, a second process
|
|
// refreshes with the same single-use refresh_token and one of the two is
|
|
// rejected. Bounding the calls ourselves keeps that under the lock without
|
|
// holding the lock longer, which the publish path also waits on.
|
|
$worstCaseSeconds = 2 * (ConnectionVerifier::REFRESH_TIMEOUT_SECONDS + ConnectionVerifier::REFRESH_CONNECT_TIMEOUT_SECONDS);
|
|
|
|
expect(ConnectionVerifier::REFRESH_LOCK_SECONDS)->toBeGreaterThan($worstCaseSeconds);
|
|
});
|
|
|
|
test('a rejected Instagram extension disconnects loudly instead of waiting for the token to die', function () {
|
|
Queue::fake();
|
|
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform' => Platform::Instagram,
|
|
'status' => Status::Connected,
|
|
'access_token' => 'still-valid-but-unextendable',
|
|
'token_expires_at' => now()->addHours(20),
|
|
]);
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
|
'error' => ['message' => 'Invalid OAuth access token', 'type' => 'OAuthException', 'code' => 190],
|
|
], 400),
|
|
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response(['id' => '1', 'username' => 'u'], 200),
|
|
]);
|
|
|
|
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Instagram/Threads tokens cannot be refreshed once expired. Staying
|
|
// Connected because the token still reads means the owner is told only
|
|
// after it dies — by which point reconnecting is the only option left.
|
|
expect($account->fresh()->status)->toBe(Status::TokenExpired);
|
|
Queue::assertPushed(SendNotification::class);
|
|
});
|
|
|
|
test('the job survives the account being deleted while it is in flight', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
|
]);
|
|
|
|
$account = $this->account;
|
|
$account->update(['token_expires_at' => now()->addMinutes(20)]);
|
|
|
|
SocialAccount::whereKey($account->id)->delete();
|
|
|
|
// Guard the repro itself: a delete that silently did nothing would make
|
|
// this test pass without ever exercising the path it claims to cover.
|
|
expect(SocialAccount::find($account->id))->toBeNull();
|
|
|
|
// tries = 1, so an escaping exception lands the job straight in failed_jobs.
|
|
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Reaching this line is the point: the refresh ran and the vanished row
|
|
// did not escape as a ModelNotFoundException.
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
|
});
|
|
|
|
test('a 200 without a token leaves the working credential intact', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => '',
|
|
'refresh_token' => 'rt-new',
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'the-token-that-still-works',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// Persisting the empty token would destroy a credential that still works,
|
|
// and no amount of after-the-fact detection gets it back.
|
|
expect($this->account->fresh()->access_token)->toBe('the-token-that-still-works');
|
|
|
|
// And it must not disconnect: the refresh_token is probably fine, so the
|
|
// next tick should retry rather than emailing the owner to reconnect.
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('refreshToken reports false for a platform with nothing to refresh', function () {
|
|
$account = SocialAccount::factory()->mastodon()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'status' => Status::Connected,
|
|
]);
|
|
|
|
expect(app(ConnectionVerifier::class)->refreshToken($account))->toBeFalse();
|
|
});
|
|
|
|
test('every platform that claims a refresh flow actually performs one', function () {
|
|
$verifier = app(ConnectionVerifier::class);
|
|
$checked = 0;
|
|
|
|
foreach (Platform::cases() as $platform) {
|
|
if (! $platform->hasTokenRefreshFlow()) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => Workspace::factory()->create()->id,
|
|
'platform' => $platform,
|
|
'status' => Status::Connected,
|
|
'refresh_token' => 'rt-seed',
|
|
'meta' => ['service' => 'https://bsky.social', 'identifier' => 'a.bsky.social'],
|
|
]);
|
|
|
|
Http::fake(['*' => Http::response([
|
|
'access_token' => 'at', 'refresh_token' => 'rt', 'expires_in' => 3600,
|
|
'accessJwt' => 'j', 'refreshJwt' => 'r', 'id' => '1', 'data' => ['id' => '1'],
|
|
], 200)]);
|
|
|
|
try {
|
|
$verifier->refreshToken($account);
|
|
} catch (UnhandledMatchError $e) {
|
|
// refreshToken()'s match has no default arm, so a platform added to
|
|
// hasTokenRefreshFlow() without one blows up in production instead
|
|
// of falling through quietly.
|
|
$this->fail("{$platform->value} claims a refresh flow but refreshToken() has no arm for it");
|
|
} catch (Throwable $e) {
|
|
$this->fail("{$platform->value} refresh threw ".$e::class.': '.$e->getMessage());
|
|
}
|
|
|
|
// A broken client chain (a renamed helper, a method that no longer
|
|
// exists on PendingRequest) raises before anything leaves the process,
|
|
// so "no request sent" is the signal that catches it.
|
|
expect(Http::recorded())
|
|
->not->toBeEmpty("{$platform->value} refresh sent no HTTP request at all");
|
|
}
|
|
|
|
expect($checked)->toBeGreaterThan(0);
|
|
});
|
|
|
|
test('no platform lets a tokenless 200 destroy the credential it already had', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = app(ConnectionVerifier::class);
|
|
$checked = 0;
|
|
|
|
foreach (Platform::cases() as $platform) {
|
|
if (! $platform->hasTokenRefreshFlow()) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
|
|
$account = SocialAccount::factory()->create([
|
|
'workspace_id' => Workspace::factory()->create()->id,
|
|
'platform' => $platform,
|
|
'status' => Status::Connected,
|
|
'access_token' => 'the-token-that-still-works',
|
|
'refresh_token' => 'rt-seed',
|
|
'meta' => ['service' => 'https://bsky.social', 'identifier' => 'a.bsky.social'],
|
|
]);
|
|
|
|
// A 200 carrying no token at all. Every provider reads a different
|
|
// field name, so this is the shape none of them can parse.
|
|
Http::fake(['*' => Http::response(['expires_in' => 3600], 200)]);
|
|
|
|
try {
|
|
$verifier->refreshToken($account);
|
|
$this->fail("{$platform->value} accepted a 200 with no token in it");
|
|
} catch (PlatformUnavailableException) {
|
|
// Correct: nothing is provably dead, so refuse and let the next
|
|
// tick retry rather than disconnecting anyone.
|
|
} catch (Throwable $e) {
|
|
// Without the guard the write reaches the database and trips the
|
|
// NOT NULL column, which also poisons the surrounding transaction.
|
|
$this->fail("{$platform->value} should refuse a tokenless 200 cleanly, got ".$e::class.': '.$e->getMessage());
|
|
}
|
|
|
|
expect($account->fresh()->access_token)
|
|
->toBe('the-token-that-still-works', "{$platform->value} overwrote a working token with nothing");
|
|
}
|
|
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
expect($checked)->toBeGreaterThan(0);
|
|
});
|
|
|
|
test('a platform outage never disconnects, not even once the token has expired', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
|
// TokenRefreshClient raises this for 5xx, 429 and connection timeouts.
|
|
new PlatformUnavailableException('X API returned 429 during token refresh', 429)
|
|
);
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
$this->account->update(['token_expires_at' => now()->subMinutes(5)]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
|
|
// A rate limit around expiry is not evidence of anything. Disconnecting
|
|
// here emails the owner and hard-fails every scheduled post, and only the
|
|
// daily sweep would undo it.
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('a platform outage on a live token stays quiet and retries', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
|
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
|
|
);
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
$this->account->update(['token_expires_at' => now()->addMinutes(20)]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle($verifier);
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('a failure the fallback cannot attribute to the token surfaces instead of passing as healthy', function () {
|
|
Queue::fake();
|
|
|
|
$verifier = mock(ConnectionVerifier::class);
|
|
$verifier->shouldReceive('refreshToken')->once()->andThrow(new TokenExpiredException('rejected'));
|
|
// e.g. a decrypt failure after an APP_KEY rotation, or an unhandled match
|
|
// for a platform someone just added.
|
|
$verifier->shouldReceive('verifyAccessToken')->once()->andThrow(new RuntimeException('cannot decrypt'));
|
|
app()->instance(ConnectionVerifier::class, $verifier);
|
|
|
|
// Swallowing this would leave the account Connected forever while every
|
|
// publish hard-fails. It has to reach failed_jobs where someone sees it —
|
|
// and it must not disconnect users, since an APP_KEY rotation breaks all
|
|
// of them at once.
|
|
expect(fn () => (new RefreshSocialToken($this->account))->handle($verifier))
|
|
->toThrow(RuntimeException::class);
|
|
|
|
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
|
Queue::assertNotPushed(SendNotification::class);
|
|
});
|
|
|
|
test('a null refresh_token in a 200 does not wipe the one we already had', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
'access_token' => 'fresh-access-token',
|
|
'refresh_token' => null,
|
|
'expires_in' => 7200,
|
|
], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'refresh_token' => 'the-refresh-token-that-still-works',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// data_get() only falls back when the key is absent, so an explicit null
|
|
// overwrites. Losing it means the next tick throws "no refresh token"
|
|
// without a single call, and the account dies with the access token.
|
|
expect($this->account->fresh()->refresh_token)->toBe('the-refresh-token-that-still-works');
|
|
});
|
|
|
|
test('a refresh already in flight on a dead token is transient, not something to publish through', function () {
|
|
$this->account->update(['token_expires_at' => now()->subMinutes(5)]);
|
|
|
|
Cache::lock("token_refresh:{$this->account->id}", 120)->get();
|
|
|
|
// Returning false here hands the caller a token it already knows is dead.
|
|
// A publisher then posts with it, gets a 401, and PublishToSocialPlatform
|
|
// finalises the post as failed and disconnects the account — for a lock
|
|
// that a worker death left behind.
|
|
expect(fn () => app(ConnectionVerifier::class)->refreshToken($this->account))
|
|
->toThrow(PlatformUnavailableException::class);
|
|
});
|
|
|
|
test('a billed fallback check counts as a verification like any other', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
|
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
|
]);
|
|
|
|
$this->account->update([
|
|
'access_token' => 'still-valid-access-token',
|
|
'token_expires_at' => now()->addMinutes(20),
|
|
'last_verified_at' => null,
|
|
]);
|
|
|
|
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
|
|
|
// GET /2/users/me is billed and it just proved the token alive. Throwing
|
|
// that away means the pre-publish check pays to ask again minutes later.
|
|
expect($this->account->fresh()->last_verified_at)->not->toBeNull();
|
|
});
|