fix(social): distinguish platform-down from token-expired
When a provider's API was down (5xx, timeout, DNS), the hourly RefreshSocialToken job and daily VerifyWorkspaceConnections job were treating it as "token revoked" and emailing the user to reconnect. Bluesky going offline triggered false-positive disconnect notifications because Bluesky access tokens are short-lived (2h) so every hourly refresh failed during the outage. - New PlatformUnavailableException: API unreachable / 5xx, transient. TokenExpiredException stays for 4xx (token is provably bad). - New TokenRefreshClient: normalizes failure semantics for OAuth refresh HTTP calls across all providers. Takes a Platform enum so typos fail at compile time and the user-facing label comes from one source. - ConnectionVerifier: all 8 refresh*Token methods route through the new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's default PDS host moved into config/trypost.php alongside the existing per-platform entries. - RefreshSocialToken job: PlatformUnavailableException → log warning and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next scheduled tick retries. - VerifyWorkspaceConnections job: PlatformUnavailableException from the inner refresh propagates and is treated as a transient skip.
This commit is contained in:
parent
a0861a0b92
commit
6f96d67dbc
9 changed files with 309 additions and 108 deletions
23
app/Exceptions/PlatformUnavailableException.php
Normal file
23
app/Exceptions/PlatformUnavailableException.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Raised when a social platform's API is unreachable or returning a server
|
||||
* error during a token verify/refresh. Distinct from TokenExpiredException
|
||||
* because the account's token is not provably invalid — the platform is
|
||||
* just down. Callers should retry later instead of disconnecting the user.
|
||||
*/
|
||||
class PlatformUnavailableException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Platform API is unavailable',
|
||||
public ?int $httpStatus = null,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\ConnectionVerifier;
|
||||
|
|
@ -24,6 +25,16 @@ public function handle(ConnectionVerifier $verifier): void
|
|||
{
|
||||
try {
|
||||
$verifier->refreshToken($this->account);
|
||||
} catch (PlatformUnavailableException $e) {
|
||||
// Platform is down (5xx / network). Leave the account alone —
|
||||
// next scheduled run will try again. Critically, do NOT mark
|
||||
// the account expired: that would trigger a false-positive
|
||||
// "reconnect your account" notification.
|
||||
Log::warning('Token refresh skipped: platform unavailable', [
|
||||
'account_id' => $this->account->id,
|
||||
'platform' => $this->account->platform->value,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (TokenExpiredException $e) {
|
||||
// refresh_token rejected by the provider (revoked / rotated /
|
||||
// expired beyond refresh). Mark the account so the user is
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Mail\WorkspaceConnectionsDisconnected;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -63,6 +64,16 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
|
|||
try {
|
||||
$verifier->verify($account);
|
||||
|
||||
return true;
|
||||
} catch (PlatformUnavailableException $e) {
|
||||
// Platform is down (5xx / network). The account's token is not
|
||||
// provably bad — skip silently and try again next pass.
|
||||
Log::warning('Social account verification skipped: platform unavailable', [
|
||||
'account_id' => $account->id,
|
||||
'platform' => $account->platform->value,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (TokenExpiredException $e) {
|
||||
Log::warning('Social account connection is invalid', [
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ConnectionVerifier
|
||||
{
|
||||
|
|
@ -17,6 +17,7 @@ class ConnectionVerifier
|
|||
* Verify that a social account connection is still valid.
|
||||
*
|
||||
* @throws TokenExpiredException if the connection is invalid
|
||||
* @throws PlatformUnavailableException if the platform's API is down
|
||||
*/
|
||||
public function verify(SocialAccount $account): bool
|
||||
{
|
||||
|
|
@ -27,6 +28,9 @@ public function verify(SocialAccount $account): bool
|
|||
// LinkedIn, etc.) invalidate the previous refresh_token on each
|
||||
// refresh, so proactive refreshes during races cause false-positive
|
||||
// disconnects even though the access_token still works fine.
|
||||
//
|
||||
// PlatformUnavailableException from refresh propagates naturally so
|
||||
// the caller can distinguish "platform is down" from "token bad".
|
||||
if ($account->is_token_expired) {
|
||||
$this->refreshToken($account);
|
||||
|
||||
|
|
@ -113,17 +117,13 @@ private function refreshLinkedInToken(SocialAccount $account): void
|
|||
throw new TokenExpiredException('No refresh token available for LinkedIn account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.linkedin.client_id'),
|
||||
'client_secret' => config('services.linkedin.client_secret'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh LinkedIn token');
|
||||
}
|
||||
$response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm()
|
||||
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.linkedin.client_id'),
|
||||
'client_secret' => config('services.linkedin.client_secret'),
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
|
|
@ -145,17 +145,12 @@ private function refreshXToken(SocialAccount $account): void
|
|||
throw new TokenExpiredException('No refresh token available for X account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()
|
||||
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
|
||||
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
|
||||
->post(config('trypost.platforms.x.api').'/oauth2/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh X token');
|
||||
}
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
|
|
@ -170,13 +165,16 @@ private function refreshXToken(SocialAccount $account): void
|
|||
|
||||
private function refreshBlueskyToken(SocialAccount $account): void
|
||||
{
|
||||
$service = $account->meta['service'] ?? 'https://bsky.social';
|
||||
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
||||
$client = TokenRefreshClient::for(Platform::Bluesky);
|
||||
|
||||
// Try refresh token first
|
||||
$response = Http::withToken($account->refresh_token)
|
||||
->post("{$service}/xrpc/com.atproto.server.refreshSession");
|
||||
// Try refresh token first. Connection errors / 5xx surface as
|
||||
// PlatformUnavailableException (Bluesky is down — don't touch the
|
||||
// account's status). 4xx falls through to the re-auth fallback.
|
||||
try {
|
||||
$response = $client->send(fn () => Http::withToken($account->refresh_token)
|
||||
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'accessJwt'),
|
||||
|
|
@ -187,35 +185,29 @@ private function refreshBlueskyToken(SocialAccount $account): void
|
|||
$account->refresh();
|
||||
|
||||
return;
|
||||
} catch (TokenExpiredException) {
|
||||
// refresh token was rejected (4xx) — fall back to re-auth below
|
||||
}
|
||||
|
||||
// If refresh fails, re-authenticate with stored credentials
|
||||
if (isset($account->meta['password'])) {
|
||||
try {
|
||||
$password = decrypt($account->meta['password']);
|
||||
$identifier = $account->meta['identifier'];
|
||||
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
|
||||
'identifier' => $account->meta['identifier'],
|
||||
'password' => decrypt($account->meta['password']),
|
||||
]));
|
||||
|
||||
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
|
||||
'identifier' => $identifier,
|
||||
'password' => $password,
|
||||
$data = $reauth->json();
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'accessJwt'),
|
||||
'refresh_token' => data_get($data, 'refreshJwt'),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'accessJwt'),
|
||||
'refresh_token' => data_get($data, 'refreshJwt'),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
]);
|
||||
$account->refresh();
|
||||
|
||||
$account->refresh();
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ConnectionVerifier: Bluesky re-authentication failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
} catch (TokenExpiredException) {
|
||||
// re-auth rejected with stored credentials — fall through
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,17 +220,13 @@ private function refreshYouTubeToken(SocialAccount $account): void
|
|||
throw new TokenExpiredException('No refresh token available for YouTube account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'client_secret' => config('services.google.client_secret'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh YouTube token');
|
||||
}
|
||||
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
|
||||
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'client_secret' => config('services.google.client_secret'),
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
|
|
@ -256,17 +244,13 @@ private function refreshTikTokToken(SocialAccount $account): void
|
|||
throw new TokenExpiredException('No refresh token available for TikTok account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_key' => config('services.tiktok.client_id'),
|
||||
'client_secret' => config('services.tiktok.client_secret'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh TikTok token');
|
||||
}
|
||||
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
|
||||
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_key' => config('services.tiktok.client_id'),
|
||||
'client_secret' => config('services.tiktok.client_secret'),
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
|
|
@ -287,18 +271,13 @@ private function refreshPinterestToken(SocialAccount $account): void
|
|||
|
||||
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
|
||||
|
||||
$response = Http::withHeaders([
|
||||
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([
|
||||
'Authorization' => "Basic {$credentials}",
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh Pinterest token');
|
||||
}
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
|
|
@ -314,15 +293,10 @@ private function refreshPinterestToken(SocialAccount $account): void
|
|||
private function refreshThreadsToken(SocialAccount $account): void
|
||||
{
|
||||
// Threads uses long-lived tokens that can be refreshed
|
||||
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_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,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh Threads token');
|
||||
}
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -338,15 +312,10 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
|
||||
private function refreshInstagramToken(SocialAccount $account): void
|
||||
{
|
||||
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_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,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]);
|
||||
throw new TokenExpiredException('Failed to refresh Instagram token');
|
||||
}
|
||||
]));
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -524,7 +493,7 @@ private function verifyPinterest(SocialAccount $account): bool
|
|||
|
||||
private function verifyBluesky(SocialAccount $account): bool
|
||||
{
|
||||
$service = $account->meta['service'] ?? 'https://bsky.social';
|
||||
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
$response = Http::withToken($account->access_token)
|
||||
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
|
||||
|
|
@ -554,23 +523,4 @@ private function verifyMastodon(SocialAccount $account): bool
|
|||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
private function redactBody(string $body): string
|
||||
{
|
||||
return preg_replace(
|
||||
[
|
||||
'/access_token=([^&"\s]+)/',
|
||||
'/"access_token"\s*:\s*"([^"]+)"/',
|
||||
'/Bearer\s+\S+/',
|
||||
'/"token"\s*:\s*"([^"]+)"/',
|
||||
],
|
||||
[
|
||||
'access_token=[REDACTED]',
|
||||
'"access_token":"[REDACTED]"',
|
||||
'Bearer [REDACTED]',
|
||||
'"token":"[REDACTED]"',
|
||||
],
|
||||
$body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
app/Services/Social/TokenRefreshClient.php
Normal file
87
app/Services/Social/TokenRefreshClient.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Closure;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Normalizes the failure modes of an OAuth token-refresh request:
|
||||
*
|
||||
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
|
||||
* - HTTP 5xx → PlatformUnavailableException
|
||||
* - HTTP 4xx → TokenExpiredException
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class TokenRefreshClient
|
||||
{
|
||||
public function __construct(public readonly Platform $platform) {}
|
||||
|
||||
public static function for(Platform $platform): self
|
||||
{
|
||||
return new self($platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure():Response $request
|
||||
*
|
||||
* @throws PlatformUnavailableException
|
||||
* @throws TokenExpiredException
|
||||
*/
|
||||
public function send(Closure $request): Response
|
||||
{
|
||||
$name = $this->platform->label();
|
||||
|
||||
try {
|
||||
$response = $request();
|
||||
} catch (ConnectionException $e) {
|
||||
throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
if ($response->serverError()) {
|
||||
throw new PlatformUnavailableException(
|
||||
"{$name} API returned {$response->status()} during token refresh",
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error("TokenRefreshClient: {$name} token refresh failed", [
|
||||
'body' => $this->redactBody($response->body()),
|
||||
]);
|
||||
throw new TokenExpiredException("Failed to refresh {$name} token");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function redactBody(string $body): string
|
||||
{
|
||||
return preg_replace(
|
||||
[
|
||||
'/access_token=([^&"\s]+)/',
|
||||
'/"access_token"\s*:\s*"([^"]+)"/',
|
||||
'/Bearer\s+\S+/',
|
||||
'/"token"\s*:\s*"([^"]+)"/',
|
||||
],
|
||||
[
|
||||
'access_token=[REDACTED]',
|
||||
'"access_token":"[REDACTED]"',
|
||||
'Bearer [REDACTED]',
|
||||
'"token":"[REDACTED]"',
|
||||
],
|
||||
$body
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,8 @@
|
|||
'linkedin' => [
|
||||
'enabled' => env('LINKEDIN_ENABLED', true),
|
||||
'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
|
||||
// OAuth host is different from the data API (api.linkedin.com).
|
||||
'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
|
||||
],
|
||||
'linkedin-page' => [
|
||||
'enabled' => env('LINKEDIN_PAGE_ENABLED', true),
|
||||
|
|
@ -80,6 +82,7 @@
|
|||
'enabled' => env('YOUTUBE_ENABLED', true),
|
||||
'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'),
|
||||
'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'),
|
||||
'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'),
|
||||
],
|
||||
'facebook' => [
|
||||
'enabled' => env('FACEBOOK_ENABLED', true),
|
||||
|
|
@ -108,6 +111,8 @@
|
|||
'bluesky' => [
|
||||
'enabled' => env('BLUESKY_ENABLED', true),
|
||||
'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'),
|
||||
// Default PDS used when the account has no `meta.service` override.
|
||||
'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'),
|
||||
],
|
||||
'mastodon' => [
|
||||
'enabled' => env('MASTODON_ENABLED', true),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
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;
|
||||
|
|
@ -67,3 +68,25 @@
|
|||
|
||||
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);
|
||||
|
||||
// Critically: account status stays Connected, no notification dispatched.
|
||||
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
||||
Queue::assertNotPushed(SendNotification::class);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\ConnectionVerifier;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('verifies account without refresh when token is not expired', function () {
|
||||
|
|
@ -360,3 +362,71 @@
|
|||
|
||||
expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () {
|
||||
Http::fake([
|
||||
'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->bluesky()->create([
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
'meta' => ['service' => 'https://bsky.social'],
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('connection failure during refresh raises PlatformUnavailableException', function () {
|
||||
Http::fake([
|
||||
'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->youtube()->create([
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('4xx during refresh keeps raising TokenExpiredException', function () {
|
||||
Http::fake([
|
||||
'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->x()->create([
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () {
|
||||
Http::fake([
|
||||
// Both endpoints return 5xx — the platform is genuinely down.
|
||||
'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503),
|
||||
'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->bluesky()->create([
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
'meta' => [
|
||||
'service' => 'https://bsky.social',
|
||||
'identifier' => 'user.bsky.social',
|
||||
'password' => encrypt('app-password'),
|
||||
],
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Jobs\VerifyWorkspaceConnections;
|
||||
use App\Mail\WorkspaceConnectionsDisconnected;
|
||||
|
|
@ -121,6 +122,26 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('job does NOT disconnect or email when platform is unavailable', function () {
|
||||
Mail::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
$verifier = mock(ConnectionVerifier::class);
|
||||
$verifier->shouldReceive('verify')->andThrow(
|
||||
new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503)
|
||||
);
|
||||
|
||||
app()->instance(ConnectionVerifier::class, $verifier);
|
||||
|
||||
VerifyWorkspaceConnections::dispatch($workspace);
|
||||
|
||||
// Status untouched, no notification email.
|
||||
expect($account->fresh()->status)->toBe(Status::Connected);
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('job skips already disconnected accounts', function () {
|
||||
Mail::fake();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue