From 02fb24c9f5260f30e558ffc13e213a68545bbee7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 17:00:38 -0300 Subject: [PATCH 1/4] Require confirmed Bluesky email and explain video upload refusals --- .ai/rules/auth.md | 4 + .ai/rules/index.md | 2 +- .../Social/BlueskyPublishException.php | 11 +- .../Controllers/Auth/BlueskyController.php | 36 ++++- .../Controllers/Auth/SocialController.php | 2 + app/Services/Social/BlueskyLexicon.php | 2 + app/Services/Social/BlueskyPublisher.php | 8 ++ lang/ar/accounts.php | 1 + lang/de/accounts.php | 1 + lang/el/accounts.php | 1 + lang/en/accounts.php | 1 + lang/es/accounts.php | 1 + lang/fr/accounts.php | 1 + lang/it/accounts.php | 1 + lang/ja/accounts.php | 1 + lang/ko/accounts.php | 1 + lang/nl/accounts.php | 1 + lang/pl/accounts.php | 1 + lang/pt-BR/accounts.php | 1 + lang/ru/accounts.php | 1 + lang/tr/accounts.php | 1 + lang/uk/accounts.php | 1 + lang/zh/accounts.php | 1 + tests/Browser/ConnectConfirmationTest.php | 30 ++++ tests/Browser/ConnectSwitchAccountTest.php | 2 + .../Jobs/PublishToSocialPlatformTest.php | 31 +++++ .../Services/Social/BlueskyPublisherTest.php | 28 ++++ .../Feature/Social/BlueskyControllerTest.php | 128 ++++++++++++++++++ .../Social/BlueskyPublishExceptionTest.php | 16 +++ 29 files changed, 310 insertions(+), 6 deletions(-) diff --git a/.ai/rules/auth.md b/.ai/rules/auth.md index a7a433418..b8398c33f 100644 --- a/.ai/rules/auth.md +++ b/.ai/rules/auth.md @@ -1,6 +1,7 @@ --- paths: - app/Http/Controllers/Auth/GoogleBusinessController.php + - 'app/Http/Controllers/Auth/**' --- # Auth @@ -10,3 +11,6 @@ Google Business (like every redirect network) keeps its access and refresh token ## Finish lands on the channel, return_to only serves exits "Finish connection" (`SocialController@finish`) always redirects to `app.channels.publish` of the connected channel (the new account, or the first one when reconnecting or refreshing) and flashes `connectedChannel` so the posting-goal dialog opens there for a new channel. The `return_to` target kept in `PendingConnection` is used only by close (X), Back, cancel and the error states (`backUrl`, "Try again"), never after a successful finish. + +## Bluesky requires a confirmed email before connection +Do not complete a Bluesky connection or reconnect unless emailConfirmed is explicitly true. TryPost requires the account to be eligible for video publishing even if text posting works. Recheck the authenticated session at Finish connection so pending logins and email changes cannot bypass the requirement. Existing accounts can lose email confirmation later: preserve unconfirmed_email as an actionable publish rejection instead of retrying or treating the session token as expired. diff --git a/.ai/rules/index.md b/.ai/rules/index.md index 9bca2c64b..718a3634c 100644 --- a/.ai/rules/index.md +++ b/.ai/rules/index.md @@ -6,7 +6,7 @@ Before planning or editing, find the row whose globs match the file's path and r | --- | --- | | app/Actions/Analytics/** | .ai/rules/analytics.md | | app/** | .ai/rules/app.md | -| app/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md | +| app/Http/Controllers/Auth/GoogleBusinessController.php, app/Http/Controllers/Auth/** | .ai/rules/auth.md | | app/Services/Analytics/Collectors/** | .ai/rules/collectors.md | | resources/js/components/posts/editor/** | .ai/rules/editor.md | | app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php | .ai/rules/google-business.md | diff --git a/app/Exceptions/Social/BlueskyPublishException.php b/app/Exceptions/Social/BlueskyPublishException.php index 164afedff..601b0ae1b 100644 --- a/app/Exceptions/Social/BlueskyPublishException.php +++ b/app/Exceptions/Social/BlueskyPublishException.php @@ -16,9 +16,18 @@ public static function fromApiResponse(mixed $response): static $body = $response->json(); $rawResponse = $response->body(); - $error = data_get($body, 'error', ''); + $error = data_get($body, 'jobStatus.error') ?? data_get($body, 'error', ''); $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); + if ($error === 'unconfirmed_email' && $response->clientError()) { + return (new static( + userMessage: 'Confirm your email in Bluesky settings, then try publishing again.', + category: ErrorCategory::Permission, + platformErrorCode: $error, + rawResponse: $rawResponse, + ))->asNetworkRejection(); + } + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage, diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index d7e225f5d..7c9253956 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Exceptions\SocialAccount\ConnectFlowException; +use App\Models\SocialAccount; use App\Services\Social\BlueskyLexicon; use App\Support\Social\PendingConnection; use Exception; @@ -51,6 +53,10 @@ public function store(Request $request): RedirectResponse $service = config('trypost.platforms.bluesky.default_service'); + if (PendingConnection::current()?->platform() !== $this->platform) { + $this->rememberConnectSession($request, $workspace); + } + try { // Authenticate with Bluesky $response = Http::post("{$service}/xrpc/".BlueskyLexicon::CREATE_SESSION, [ @@ -69,6 +75,12 @@ public function store(Request $request): RedirectResponse $data = $response->json(); + if (data_get($data, 'emailConfirmed') !== true) { + return $this->failConnection(data_get($data, 'emailConfirmed') === false + ? 'bluesky_email_unconfirmed' + : 'error_connecting'); + } + // Get profile $profileResponse = Http::withToken(data_get($data, 'accessJwt')) ->get("{$service}/xrpc/".BlueskyLexicon::GET_PROFILE, [ @@ -77,10 +89,6 @@ public function store(Request $request): RedirectResponse $profile = $profileResponse->successful() ? $profileResponse->json() : []; - if (PendingConnection::current()?->platform() !== $this->platform) { - $this->rememberConnectSession($request, $workspace); - } - return $this->offerIdentities($workspace, [ PendingConnection::identity( $this->platform, @@ -114,4 +122,24 @@ public function store(Request $request): RedirectResponse throw ValidationException::withMessages(['password' => __('accounts.bluesky.connection_error')]); } } + + protected function accountValues(array $identity, ?SocialAccount $reconnect): array + { + $response = Http::withToken(data_get($identity, 'attributes.access_token')) + ->connectTimeout(10) + ->timeout(30) + ->get(config('trypost.platforms.bluesky.default_service').'/xrpc/'.BlueskyLexicon::GET_SESSION); + + if (! $response->successful() || $response->json('did') !== data_get($identity, 'platform_user_id')) { + throw new ConnectFlowException('error_connecting', $this->platform); + } + + if ($response->json('emailConfirmed') !== true) { + throw new ConnectFlowException($response->json('emailConfirmed') === false + ? 'bluesky_email_unconfirmed' + : 'error_connecting', $this->platform); + } + + return parent::accountValues($identity, $reconnect); + } } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 95d461fe4..2bb338b38 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -127,6 +127,8 @@ public function finish(FinishSocialConnectionRequest $request): RedirectResponse try { $accounts = $this->connectIdentities($workspace, $pending->selected($request->validated('identities')), $reconnect); + } catch (ConnectFlowException $e) { + return $this->failConnection($e->messageKey); } catch (NetworkAlreadyConnectedException $e) { return $this->failConnection($e->messageKey); } catch (Exception $e) { diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 5f5770931..6e436edca 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -21,6 +21,8 @@ final class BlueskyLexicon public const CREATE_SESSION = 'com.atproto.server.createSession'; + public const GET_SESSION = 'com.atproto.server.getSession'; + public const REFRESH_SESSION = 'com.atproto.server.refreshSession'; public const GET_SERVICE_AUTH = 'com.atproto.server.getServiceAuth'; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index e7e04628a..53ebd9861 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -510,6 +510,8 @@ private function uploadVideo(SocialAccount $account, string $service, string $ur } return null; + } catch (BlueskyPublishException $e) { + throw $e; } catch (Throwable $e) { Log::error('Bluesky video upload exception', [ 'error' => $e->getMessage(), @@ -554,6 +556,12 @@ private function attemptVideoUpload(SocialAccount $account, string $uploadToken, // A re-upload of identical bytes returns 409 with the already-finished // job, whose blob we can embed directly. if ($response->failed() && $response->status() !== 409) { + $exception = BlueskyPublishException::fromApiResponse($response); + + if ($exception->isNetworkRejection()) { + throw $exception; + } + Log::error('Bluesky video upload failed', [ 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 6b7161aee..39eb84706 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'ابدأ من جديد', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'أكّد بريدك الإلكتروني في إعدادات Bluesky، ثم اربط حسابك مجددًا. يتطلب TryPost بريدًا إلكترونيًا مؤكدًا لنشر الفيديوهات.', 'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.', 'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.', 'wrong_account' => 'هذا حساب مختلف. صرّح بالحساب الذي تعيد ربطه.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 187d3411e..cc84b3342 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -167,6 +167,7 @@ 'start_again' => 'Neu starten', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Bestätige deine E-Mail-Adresse in den Bluesky-Einstellungen und verbinde dein Konto erneut. TryPost benötigt eine bestätigte E-Mail-Adresse, um Videos zu veröffentlichen.', 'error_connecting' => 'Fehler beim Verbinden des Kontos. Bitte versuche es erneut.', 'network_taken' => 'Dieser Workspace hat bereits ein Konto für dieses Netzwerk. Trenne es zuerst.', 'wrong_account' => 'Das ist ein anderes Konto. Autorisiere das Konto, das du neu verbindest.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 1a905fd72..4957225ba 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Από την αρχή', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Επιβεβαιώστε το email σας στις ρυθμίσεις του Bluesky και συνδέστε ξανά τον λογαριασμό σας. Το TryPost απαιτεί επιβεβαιωμένο email για τη δημοσίευση βίντεο.', 'error_connecting' => 'Σφάλμα κατά τη σύνδεση του λογαριασμού. Παρακαλούμε δοκιμάστε ξανά.', 'network_taken' => 'Αυτό το workspace έχει ήδη λογαριασμό για αυτό το δίκτυο. Αποσυνδέστε τον πρώτα.', 'wrong_account' => 'Αυτός είναι διαφορετικός λογαριασμός. Εξουσιοδοτήστε αυτόν που επανασυνδέετε.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ac5dd94cc..45181f1a8 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Start again', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Confirm your email in Bluesky settings, then connect your account again. TryPost requires a confirmed email to publish videos.', 'error_connecting' => 'Error connecting account. Please try again.', 'network_taken' => 'This workspace already has an account for this network. Disconnect it first.', 'wrong_account' => 'That is a different account. Authorize the one you are reconnecting.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index a74daa00b..b18daf7a9 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Empezar de nuevo', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Confirma tu correo en los ajustes de Bluesky y vuelve a conectar tu cuenta. TryPost requiere un correo confirmado para publicar vídeos.', 'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.', 'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.', 'wrong_account' => 'Esa es una cuenta diferente. Autoriza la que estás reconectando.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index be587ae7a..dd7d493d7 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Recommencer', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Confirmez votre adresse e-mail dans les paramètres de Bluesky, puis reconnectez votre compte. TryPost exige une adresse confirmée pour publier des vidéos.', 'error_connecting' => 'Erreur lors de la connexion du compte. Veuillez réessayer.', 'network_taken' => 'Cet espace de travail a déjà un compte pour ce réseau. Déconnectez-le d\'abord.', 'wrong_account' => 'C\'est un autre compte. Autorisez celui que vous reconnectez.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index c43876c1f..829199875 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Ricomincia', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Conferma la tua email nelle impostazioni di Bluesky, poi collega di nuovo il tuo account. TryPost richiede un’email confermata per pubblicare video.', 'error_connecting' => 'Errore durante il collegamento dell\'account. Riprova.', 'network_taken' => 'Questo workspace ha già un account per questa rete. Scollegalo prima.', 'wrong_account' => 'Questo è un account diverso. Autorizza quello che stai ricollegando.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index b787fde68..2c9abb051 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -165,6 +165,7 @@ 'start_again' => '最初から', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Blueskyの設定でメールアドレスを確認してから、アカウントを再接続してください。TryPostで動画を投稿するには、メールアドレスの確認が必要です。', 'error_connecting' => 'アカウントの接続中にエラーが発生しました。もう一度お試しください。', 'network_taken' => 'このワークスペースにはすでにこのネットワークのアカウントが接続されています。先に接続を解除してください。', 'wrong_account' => '別のアカウントです。再接続するアカウントを認証してください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 540bdfea3..609f9d0d6 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -165,6 +165,7 @@ 'start_again' => '처음부터', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Bluesky 설정에서 이메일을 인증한 후 계정을 다시 연결하세요. TryPost에서 동영상을 게시하려면 이메일 인증이 필요합니다.', 'error_connecting' => '계정 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', 'network_taken' => '이 워크스페이스에는 이미 이 네트워크의 계정이 있습니다. 먼저 연결을 해제하세요.', 'wrong_account' => '다른 계정입니다. 다시 연결하려는 계정을 인증하세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 9a42f366b..be7861198 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Opnieuw beginnen', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Bevestig je e-mailadres in de instellingen van Bluesky en verbind je account opnieuw. TryPost vereist een bevestigd e-mailadres om video’s te publiceren.', 'error_connecting' => 'Fout bij het koppelen van het account. Probeer het opnieuw.', 'network_taken' => 'Deze workspace heeft al een account voor dit netwerk. Koppel dat eerst los.', 'wrong_account' => 'Dat is een ander account. Autoriseer het account dat je opnieuw koppelt.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index c258b3e9b..a4ff4125b 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Zacznij od nowa', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Potwierdź adres e-mail w ustawieniach Bluesky, a następnie ponownie połącz konto. TryPost wymaga potwierdzonego adresu e-mail, aby publikować filmy.', 'error_connecting' => 'Błąd podczas łączenia konta. Spróbuj ponownie.', 'network_taken' => 'Ta przestrzeń robocza ma już konto dla tej sieci. Najpierw je rozłącz.', 'wrong_account' => 'To inne konto. Autoryzuj to, które ponownie łączysz.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index e67ab1d48..3f6f7d332 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Começar de novo', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Confirme seu e-mail nas configurações do Bluesky e conecte sua conta novamente. O TryPost exige um e-mail confirmado para publicar vídeos.', 'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.', 'network_taken' => 'Este workspace já tem uma conta para esta rede. Desconecte-a primeiro.', 'wrong_account' => 'Essa é outra conta. Autorize a que você está reconectando.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index d961e6d29..ceaf3f000 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Начать заново', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Подтвердите электронную почту в настройках Bluesky, затем подключите аккаунт снова. Для публикации видео через TryPost нужна подтверждённая почта.', 'error_connecting' => 'Ошибка при подключении аккаунта. Попробуйте ещё раз.', 'network_taken' => 'К этому рабочему пространству уже подключён аккаунт этой сети. Сначала отключите его.', 'wrong_account' => 'Это другой аккаунт. Авторизуйте тот, который вы переподключаете.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 875564a6b..9d82088de 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -167,6 +167,7 @@ 'start_again' => 'Baştan başla', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Bluesky ayarlarından e-posta adresinizi doğrulayın, ardından hesabınızı yeniden bağlayın. TryPost ile video paylaşmak için doğrulanmış bir e-posta adresi gereklidir.', 'error_connecting' => 'Hesap bağlanırken hata oluştu. Lütfen tekrar deneyin.', 'network_taken' => 'Bu çalışma alanında bu ağa ait zaten bir hesap var. Önce bağlantısını kesin.', 'wrong_account' => 'Bu farklı bir hesap. Yeniden bağladığınız hesabı yetkilendirin.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index d69783042..0fce3c21d 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -165,6 +165,7 @@ 'start_again' => 'Почати знову', ], 'errors' => [ + 'bluesky_email_unconfirmed' => 'Підтвердьте електронну пошту в налаштуваннях Bluesky, а потім підключіть обліковий запис знову. Для публікації відео через TryPost потрібна підтверджена пошта.', 'error_connecting' => 'Помилка підключення акаунта. Спробуйте ще раз.', 'network_taken' => 'У цьому робочому просторі вже є акаунт для цієї мережі. Спочатку від’єднайте його.', 'wrong_account' => 'Це інший акаунт. Авторизуйте той, який ви перепідключаєте.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index d5d36886c..90ca4705a 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -165,6 +165,7 @@ 'start_again' => '重新开始', ], 'errors' => [ + 'bluesky_email_unconfirmed' => '请在 Bluesky 设置中验证邮箱,然后重新连接账号。通过 TryPost 发布视频需要先验证邮箱。', 'error_connecting' => '连接账号时出错,请重试。', 'network_taken' => '此工作区已连接了该网络的账号。请先断开该连接。', 'wrong_account' => '这是另一个账号。请授权你正在重新连接的那个。', diff --git a/tests/Browser/ConnectConfirmationTest.php b/tests/Browser/ConnectConfirmationTest.php index 7747d3cd8..371a6a767 100644 --- a/tests/Browser/ConnectConfirmationTest.php +++ b/tests/Browser/ConnectConfirmationTest.php @@ -353,14 +353,44 @@ function connectConfirmPath(string $route, mixed ...$parameters): string 'mastodon' => ['app.social.mastodon.connect', 'mastodon', 'accounts.mastodon.title', 'mastodon-instance-hint'], ]); +test('bluesky shows the email confirmation requirement instead of allowing connection', function () { + $user = connectConfirmAdmin(); + $this->actingAs($user); + + Http::fake([ + config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.server.createSession' => Http::response([ + 'did' => 'did:plc:unconfirmed', + 'handle' => 'unconfirmed.bsky.social', + 'accessJwt' => 'access', + 'refreshJwt' => 'refresh', + 'emailConfirmed' => false, + ]), + ]); + + $page = visit(route('app.social.bluesky.connect'))->resize(1280, 900); + waitForConnectConfirmTestId($page, 'bluesky-identifier'); + $page->type('@bluesky-identifier', 'unconfirmed.bsky.social') + ->type('@bluesky-password', 'xxxx-xxxx-xxxx-xxxx') + ->click('@bluesky-submit'); + + waitForConnectConfirmTestId($page, 'connect-state-error'); + $page->assertSee(__('accounts.connect.errors.bluesky_email_unconfirmed')) + ->assertMissing('@connect-finish') + ->assertNoJavaScriptErrors(); + + expect($user->currentWorkspace->socialAccounts()->count())->toBe(0); +}); + test('a bluesky connection started without a return page finishes on its channel page', function () { $user = connectConfirmAdmin(); $this->actingAs($user); $service = config('trypost.platforms.bluesky.default_service'); Http::fake([ "{$service}/xrpc/com.atproto.server.createSession" => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:confirm', 'handle' => 'confirm.bsky.social', 'accessJwt' => 'access', 'refreshJwt' => 'refresh', ]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:confirm', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response(['displayName' => 'Confirm Bluesky']), ]); diff --git a/tests/Browser/ConnectSwitchAccountTest.php b/tests/Browser/ConnectSwitchAccountTest.php index 12da87c2c..89770a70c 100644 --- a/tests/Browser/ConnectSwitchAccountTest.php +++ b/tests/Browser/ConnectSwitchAccountTest.php @@ -158,11 +158,13 @@ function switchAccountPath(string $route, mixed ...$parameters): string Http::fake([ "{$service}/xrpc/com.atproto.server.createSession" => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:first', 'handle' => 'first.bsky.social', 'accessJwt' => 'access-token', 'refreshJwt' => 'refresh-token', ]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:first', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:first', 'handle' => 'first.bsky.social', diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index ad21559d1..b0dec1b13 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -13,6 +13,7 @@ use App\Enums\TikTok\PrivacyLevel; use App\Events\PostPlatformStatusUpdated; use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\ContentLimitException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; @@ -31,6 +32,7 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Media\MediaOptimizer; +use App\Services\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; use App\Services\Social\FacebookPublisher; use App\Services\Social\LinkedInPagePublisher; @@ -273,6 +275,35 @@ 'permission' => fn () => linkedInRejection(403), ]); +test('a Bluesky email rejection saves actionable failure details without refreshing the valid account', function () { + $this->socialAccount->update(['platform' => Platform::Bluesky]); + $this->postPlatform->update(['platform' => Platform::Bluesky, 'content_type' => ContentType::BlueskyPost]); + Event::fake(); + Exceptions::fake(); + Queue::fake(); + + $body = ['jobStatus' => ['error' => 'unconfirmed_email']]; + $response = Http::fake(['*' => Http::response($body, 401)]) + ->post('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); + $exception = BlueskyPublishException::fromApiResponse($response); + + $this->mock(BlueskyPublisher::class)->shouldReceive('publish')->once()->andThrow($exception); + $this->mock(ConnectionVerifier::class)->shouldNotReceive('refreshToken'); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed) + ->and($this->postPlatform->error_message)->toBe('Confirm your email in Bluesky settings, then try publishing again.') + ->and(data_get($this->postPlatform->error_context, 'category'))->toBe(ErrorCategory::Permission->value) + ->and(data_get($this->postPlatform->error_context, 'platform_error_code'))->toBe('unconfirmed_email') + ->and(json_decode(data_get($this->postPlatform->error_context, 'raw_response'), true))->toBe($body) + ->and($this->socialAccount->fresh()->status)->toBe(AccountStatus::Connected); + + Queue::assertNotPushed(PublishToSocialPlatform::class); + Exceptions::assertNothingReported(); +}); + test('publish reports a failure that can be ours, even when categorized', function (SocialPublishException $exception) { Event::fake(); Exceptions::fake(); diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 9886fc39f..3bb513954 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -6,6 +6,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -832,6 +833,33 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool }); }); +test('bluesky publisher stops after one upload when the account email is unconfirmed', function () { + attachBlueskyVideo($this->post); + + $body = ['jobStatus' => ['did' => '', 'error' => 'unconfirmed_email', 'jobId' => '', 'state' => '']]; + + Http::fake([ + 'https://example.com/media/*' => Http::response(str_repeat('v', 2048)), + 'https://plc.directory/*' => Http::response(['service' => []]), + 'https://bsky.social/xrpc/com.atproto.server.getServiceAuth*' => Http::response(['token' => 'service-auth-token']), + 'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo*' => Http::response($body, 401), + ]); + + try { + $this->publisher->publish($this->postPlatform); + $this->fail('The unconfirmed email must stop publication.'); + } catch (BlueskyPublishException $exception) { + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('unconfirmed_email') + ->and(json_decode($exception->rawResponse, true))->toBe($body) + ->and($exception->userMessage)->toBe('Confirm your email in Bluesky settings, then try publishing again.') + ->and($exception->isNetworkRejection())->toBeTrue(); + } + + expect(Http::recorded(fn ($request) => str_contains($request->url(), '/app.bsky.video.uploadVideo')))->toHaveCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/app.bsky.video.getJobStatus') || str_contains($request->url(), 'createRecord')); +}); + test('bluesky publisher stops publication when video processing fails', function () { $this->post->update([ 'media' => [[ diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php index 14d289bef..d7b929caa 100644 --- a/tests/Feature/Social/BlueskyControllerTest.php +++ b/tests/Feature/Social/BlueskyControllerTest.php @@ -8,7 +8,9 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Support\Social\PendingConnection; use Illuminate\Support\Facades\Http; +use Inertia\Testing\AssertableInertia; beforeEach(function () { $this->user = User::factory()->create(); @@ -26,11 +28,13 @@ test('user can connect bluesky account with valid credentials', function () { Http::fake([ 'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', 'accessJwt' => 'test-access-token', 'refreshJwt' => 'test-refresh-token', ], 200), + 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:testuser123', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -79,6 +83,124 @@ ]); }); +test('bluesky refuses login without a confirmed email and discards pending credentials', function (array $verification, string $reason) { + Http::fake([ + 'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([ + 'did' => 'did:plc:testuser123', + 'handle' => 'testuser.bsky.social', + 'accessJwt' => 'secret-access', + 'refreshJwt' => 'secret-refresh', + ...$verification, + ]), + ]); + + $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [ + 'identifier' => 'testuser.bsky.social', + 'password' => 'xxxx-xxxx-xxxx-xxxx', + ])->assertRedirect(route('app.social.connect.show', Platform::Bluesky)); + + expect(PendingConnection::current()->failure())->toBe($reason) + ->and(PendingConnection::current()->identities())->toBe([]) + ->and(session('social_connect.identities'))->toBeNull() + ->and($this->workspace->socialAccounts()->count())->toBe(0); + + $this->get(route('app.social.connect.show', Platform::Bluesky)) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/ConnectFinish') + ->where('state', 'error') + ->where('reason', __("accounts.connect.errors.{$reason}")) + ->has('identities', 0)); + + $this->post(route('app.social.connect.finish', Platform::Bluesky), [ + 'identities' => ['bluesky:did:plc:testuser123'], + ])->assertRedirect(); + + expect($this->workspace->socialAccounts()->count())->toBe(0); + Http::assertSentCount(1); +})->with([ + 'unconfirmed' => [['emailConfirmed' => false], 'bluesky_email_unconfirmed'], + 'missing' => [[], 'error_connecting'], + 'null' => [['emailConfirmed' => null], 'error_connecting'], + 'malformed' => [['emailConfirmed' => 'true'], 'error_connecting'], +]); + +test('bluesky rechecks email at finish and keeps an existing account unchanged on refusal', function (bool $reconnecting) { + $account = $reconnecting ? SocialAccount::factory()->bluesky()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'did:plc:testuser123', + 'access_token' => 'original-token', + ]) : null; + + startSocialConnect($this->workspace, Platform::Bluesky, $account); + + Http::fake([ + 'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([ + 'did' => 'did:plc:testuser123', + 'handle' => 'testuser.bsky.social', + 'accessJwt' => 'fresh-access', + 'refreshJwt' => 'fresh-refresh', + 'emailConfirmed' => true, + ]), + 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response(['displayName' => 'Test User']), + 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response([ + 'did' => 'did:plc:testuser123', + 'emailConfirmed' => false, + ]), + ]); + + $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [ + 'identifier' => 'testuser.bsky.social', + 'password' => 'xxxx-xxxx-xxxx-xxxx', + ])->assertRedirect(); + + expect(PendingConnection::current()->isReady())->toBeTrue(); + + finishSocialConnect(Platform::Bluesky)->assertRedirect(route('app.social.connect.show', Platform::Bluesky)); + + expect(PendingConnection::current()->failure())->toBe('bluesky_email_unconfirmed') + ->and(session('social_connect.identities'))->toBeNull() + ->and($this->workspace->socialAccounts()->count())->toBe($reconnecting ? 1 : 0); + + if ($account !== null) { + expect($account->fresh()->access_token)->toBe('original-token'); + } + + Http::assertSent(fn ($request) => str_ends_with($request->url(), '/com.atproto.server.getSession') + && $request->hasHeader('Authorization', 'Bearer fresh-access')); +})->with(['new connection' => false, 'reconnect' => true]); + +test('bluesky refuses an older pending connection when session verification cannot confirm the account', function (array $body, int $status) { + startSocialConnect($this->workspace, Platform::Bluesky)->offer([ + PendingConnection::identity(Platform::Bluesky, 'did:plc:testuser123', 'Test User', 'testuser.bsky.social', null, 'profile', [ + 'access_token' => 'pending-access', + 'refresh_token' => 'pending-refresh', + ]), + ]); + + Http::fake(['https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response($body, $status)]); + + $this->actingAs($this->user); + finishSocialConnect(Platform::Bluesky)->assertRedirect(route('app.social.connect.show', Platform::Bluesky)); + + expect(PendingConnection::current()->isReady())->toBeFalse() + ->and(session('social_connect.identities'))->toBeNull() + ->and($this->workspace->socialAccounts()->count())->toBe(0); +})->with([ + 'unconfirmed email' => [['did' => 'did:plc:testuser123', 'emailConfirmed' => false], 200], + 'missing confirmation' => [['did' => 'did:plc:testuser123'], 200], + 'different identity' => [['did' => 'did:plc:other', 'emailConfirmed' => true], 200], + 'expired token' => [['error' => 'ExpiredToken'], 401], + 'provider unavailable' => [[], 503], +]); + +test('the bluesky email confirmation message exists in every locale', function () { + foreach (Locale::cases() as $locale) { + $translations = require lang_path("{$locale->value}/accounts.php"); + + expect(data_get($translations, 'connect.errors.bluesky_email_unconfirmed'))->toBeString()->not->toBeEmpty(); + } +}); + test('user can connect multiple bluesky accounts', function () { SocialAccount::factory()->bluesky()->create([ @@ -88,11 +210,13 @@ Http::fake([ 'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:newuser456', 'handle' => 'newuser.bsky.social', 'accessJwt' => 'test-access-token', 'refreshJwt' => 'test-refresh-token', ], 200), + 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:newuser456', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:newuser456', 'handle' => 'newuser.bsky.social', @@ -136,11 +260,13 @@ Http::fake([ "{$service}/xrpc/com.atproto.server.createSession" => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', 'accessJwt' => 'fresh-access-token', 'refreshJwt' => 'fresh-refresh-token', ], 200), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:testuser123', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -177,11 +303,13 @@ Http::fake([ "{$service}/xrpc/com.atproto.server.createSession" => Http::response([ + 'emailConfirmed' => true, 'did' => 'did:plc:someoneelse999', 'handle' => 'someone-else.bsky.social', 'accessJwt' => 'other-access-token', 'refreshJwt' => 'other-refresh-token', ], 200), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:someoneelse999', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:someoneelse999', 'handle' => 'someone-else.bsky.social', diff --git a/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php b/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php index e76631f15..ccae59121 100644 --- a/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php @@ -7,6 +7,22 @@ use App\Exceptions\TokenExpiredException; use Illuminate\Support\Facades\Http; +test('an unconfirmed Bluesky email is a user action with the original error preserved', function (array $body) { + $response = Http::fake(['*' => Http::response($body, 401)]) + ->post('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); + + $exception = BlueskyPublishException::fromApiResponse($response); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('unconfirmed_email') + ->and($exception->rawResponse)->toBe($response->body()) + ->and($exception->userMessage)->toBe('Confirm your email in Bluesky settings, then try publishing again.') + ->and($exception->isNetworkRejection())->toBeTrue(); +})->with([ + 'video service' => [['jobStatus' => ['did' => '', 'error' => 'unconfirmed_email', 'jobId' => '', 'state' => '']]], + 'top level' => [['error' => 'unconfirmed_email']], +]); + test('ExpiredToken error throws TokenExpiredException', function () { $response = Http::response(['error' => 'ExpiredToken', 'message' => 'Token has expired.'], 401); $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); From 9b75918d8e7cece97b8a40a1cb6bba66aac314ad Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 17:05:51 -0300 Subject: [PATCH 2/4] Preserve Bluesky video retries outside email rejections --- .../Social/BlueskyPublishException.php | 13 +++++++--- .../Controllers/Auth/SocialController.php | 4 +-- app/Services/Social/BlueskyPublisher.php | 12 ++++----- .../Services/Social/BlueskyPublisherTest.php | 26 +++++++++++++++++++ .../Feature/Social/BlueskyControllerTest.php | 18 +++++++++++++ 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/app/Exceptions/Social/BlueskyPublishException.php b/app/Exceptions/Social/BlueskyPublishException.php index 601b0ae1b..928adfd41 100644 --- a/app/Exceptions/Social/BlueskyPublishException.php +++ b/app/Exceptions/Social/BlueskyPublishException.php @@ -16,14 +16,14 @@ public static function fromApiResponse(mixed $response): static $body = $response->json(); $rawResponse = $response->body(); - $error = data_get($body, 'jobStatus.error') ?? data_get($body, 'error', ''); + $error = data_get($body, 'error', ''); $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); - if ($error === 'unconfirmed_email' && $response->clientError()) { + if (self::isEmailUnconfirmed($response)) { return (new static( userMessage: 'Confirm your email in Bluesky settings, then try publishing again.', category: ErrorCategory::Permission, - platformErrorCode: $error, + platformErrorCode: 'unconfirmed_email', rawResponse: $rawResponse, ))->asNetworkRejection(); } @@ -84,6 +84,13 @@ public function platform(): string return 'bluesky'; } + public static function isEmailUnconfirmed(Response $response): bool + { + $error = $response->json('jobStatus.error') ?? $response->json('error'); + + return $response->clientError() && $error === 'unconfirmed_email'; + } + /** * Whether this response confirms the account's own session token is dead * (not merely a transient or content-specific failure). Shared with diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 2bb338b38..0540b0665 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -127,9 +127,7 @@ public function finish(FinishSocialConnectionRequest $request): RedirectResponse try { $accounts = $this->connectIdentities($workspace, $pending->selected($request->validated('identities')), $reconnect); - } catch (ConnectFlowException $e) { - return $this->failConnection($e->messageKey); - } catch (NetworkAlreadyConnectedException $e) { + } catch (ConnectFlowException|NetworkAlreadyConnectedException $e) { return $this->failConnection($e->messageKey); } catch (Exception $e) { Log::error('Social connect error', [ diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 53ebd9861..caa4bef60 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -444,8 +444,10 @@ private function downloadToTempFile(string $url, string $prefix, int $timeoutSec * 4. poll app.bsky.video.getJobStatus until the blob is ready, * retrying the whole upload a few times on a transient transcode failure. * - * Returns null on failure so mediaEmbed stops publication. Only a link - * preview thumbnail may be omitted when its upload fails. + * Returns null on other failures so mediaEmbed stops publication. Only a + * link preview thumbnail may be omitted when its upload fails. + * + * @throws BlueskyPublishException when the account email is unconfirmed. */ private function uploadVideo(SocialAccount $account, string $service, string $url, ?string $mimeType): ?array { @@ -556,10 +558,8 @@ private function attemptVideoUpload(SocialAccount $account, string $uploadToken, // A re-upload of identical bytes returns 409 with the already-finished // job, whose blob we can embed directly. if ($response->failed() && $response->status() !== 409) { - $exception = BlueskyPublishException::fromApiResponse($response); - - if ($exception->isNetworkRejection()) { - throw $exception; + if (BlueskyPublishException::isEmailUnconfirmed($response)) { + throw BlueskyPublishException::fromApiResponse($response); } Log::error('Bluesky video upload failed', [ diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 3bb513954..348b43bd5 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -860,6 +860,32 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool Http::assertNotSent(fn ($request) => str_contains($request->url(), '/app.bsky.video.getJobStatus') || str_contains($request->url(), 'createRecord')); }); +test('bluesky publisher preserves video upload retries for other service failures', function (array|string $body, int $status) { + attachBlueskyVideo($this->post); + + Http::fake([ + 'https://example.com/media/*' => Http::response(str_repeat('v', 2048)), + 'https://plc.directory/*' => Http::response(['service' => []]), + 'https://bsky.social/xrpc/com.atproto.server.getServiceAuth*' => Http::response(['token' => 'service-auth-token']), + 'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo*' => Http::sequence() + ->push($body, $status) + ->push(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafretry'], 'mimeType' => 'video/mp4', 'size' => 2048]]), + 'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3retry', + 'cid' => 'bafpost', + ]), + ]); + + expect($this->publisher->publish($this->postPlatform)['id'])->toBe('3retry'); + expect(Http::recorded(fn ($request) => str_contains($request->url(), '/app.bsky.video.uploadVideo')))->toHaveCount(2); +})->with([ + 'service token expired' => [['error' => 'ExpiredToken'], 401], + 'service token invalid' => [['error' => 'InvalidToken'], 401], + 'unexpected error shape' => [['error' => ['message' => 'Unavailable']], 503], + 'non-json response' => ['Service unavailable', 503], + 'email error with a server failure' => [['jobStatus' => ['error' => 'unconfirmed_email']], 503], +]); + test('bluesky publisher stops publication when video processing fails', function () { $this->post->update([ 'media' => [[ diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php index d7b929caa..2c0dc163b 100644 --- a/tests/Feature/Social/BlueskyControllerTest.php +++ b/tests/Feature/Social/BlueskyControllerTest.php @@ -201,6 +201,24 @@ } }); +test('bluesky discards pending credentials when session verification cannot connect', function () { + startSocialConnect($this->workspace, Platform::Bluesky)->offer([ + PendingConnection::identity(Platform::Bluesky, 'did:plc:testuser123', 'Test User', 'testuser.bsky.social', null, 'profile', [ + 'access_token' => 'pending-access', + 'refresh_token' => 'pending-refresh', + ]), + ]); + + Http::fake(['https://bsky.social/xrpc/com.atproto.server.getSession' => Http::failedConnection()]); + + $this->actingAs($this->user); + finishSocialConnect(Platform::Bluesky)->assertRedirect(route('app.social.connect.show', Platform::Bluesky)); + + expect(PendingConnection::current()->failure())->toBe('error_connecting') + ->and(session('social_connect.identities'))->toBeNull() + ->and($this->workspace->socialAccounts()->count())->toBe(0); +}); + test('user can connect multiple bluesky accounts', function () { SocialAccount::factory()->bluesky()->create([ From b95a89d319d61c63417f44371a5978397743b1ac Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 17:14:32 -0300 Subject: [PATCH 3/4] Centralize Bluesky session validation in a typed DTO --- app/Dto/BlueskySession.php | 72 ++++++++++++ .../Controllers/Auth/BlueskyController.php | 37 ++++--- app/Services/Social/ConnectionVerifier.php | 37 ++++--- tests/Browser/ChannelPostingGoalTest.php | 6 + tests/Browser/ConnectConfirmationTest.php | 2 +- tests/Browser/ConnectSwitchAccountTest.php | 2 +- tests/Feature/Jobs/RefreshSocialTokenTest.php | 2 + .../Social/ConnectionVerifierTest.php | 70 ++++++++++++ .../Feature/Social/BlueskyControllerTest.php | 45 ++++++-- tests/Unit/Dto/BlueskySessionTest.php | 104 ++++++++++++++++++ 10 files changed, 337 insertions(+), 40 deletions(-) create mode 100644 app/Dto/BlueskySession.php create mode 100644 tests/Unit/Dto/BlueskySessionTest.php diff --git a/app/Dto/BlueskySession.php b/app/Dto/BlueskySession.php new file mode 100644 index 000000000..8ff21b365 --- /dev/null +++ b/app/Dto/BlueskySession.php @@ -0,0 +1,72 @@ +json(); + + if (! $response->successful() || ! is_array($data)) { + return null; + } + + $emailConfirmed = data_get($data, 'emailConfirmed'); + + if ($emailConfirmed !== null && ! is_bool($emailConfirmed)) { + return null; + } + + $validator = Validator::make($data, [ + 'did' => ['required', 'string'], + 'handle' => ['required', 'string'], + 'accessJwt' => ['sometimes', 'required', 'string'], + 'refreshJwt' => ['sometimes', 'required', 'string'], + ]); + + if ($validator->fails()) { + return null; + } + + $data = $validator->validated(); + + return new self( + did: $data['did'], + handle: $data['handle'], + accessToken: data_get($data, 'accessJwt'), + refreshToken: data_get($data, 'refreshJwt'), + emailConfirmed: $emailConfirmed, + ); + } + + public function hasTokens(): bool + { + return $this->accessToken !== null && $this->refreshToken !== null; + } + + /** + * @return array{did: string, handle: string, emailConfirmed: ?bool} + */ + public function __debugInfo(): array + { + return [ + 'did' => $this->did, + 'handle' => $this->handle, + 'emailConfirmed' => $this->emailConfirmed, + ]; + } +} diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index 7c9253956..b74d77225 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; +use App\Dto\BlueskySession; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Exceptions\SocialAccount\ConnectFlowException; use App\Models\SocialAccount; @@ -73,18 +74,22 @@ public function store(Request $request): RedirectResponse throw ValidationException::withMessages(['password' => __('accounts.bluesky.invalid_credentials')]); } - $data = $response->json(); + $session = BlueskySession::fromResponse($response); - if (data_get($data, 'emailConfirmed') !== true) { - return $this->failConnection(data_get($data, 'emailConfirmed') === false + if ($session === null || ! $session->hasTokens()) { + return $this->failConnection('error_connecting'); + } + + if ($session->emailConfirmed !== true) { + return $this->failConnection($session->emailConfirmed === false ? 'bluesky_email_unconfirmed' : 'error_connecting'); } // Get profile - $profileResponse = Http::withToken(data_get($data, 'accessJwt')) + $profileResponse = Http::withToken($session->accessToken) ->get("{$service}/xrpc/".BlueskyLexicon::GET_PROFILE, [ - 'actor' => data_get($data, 'did'), + 'actor' => $session->did, ]); $profile = $profileResponse->successful() ? $profileResponse->json() : []; @@ -92,16 +97,16 @@ public function store(Request $request): RedirectResponse return $this->offerIdentities($workspace, [ PendingConnection::identity( $this->platform, - (string) data_get($data, 'did'), - data_get($profile, 'displayName') ?: data_get($data, 'handle'), - data_get($data, 'handle'), + $session->did, + data_get($profile, 'displayName') ?: $session->handle, + $session->handle, data_get($profile, 'avatar'), $this->platform->identityType()->value, [ - 'username' => data_get($data, 'handle'), - 'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')), - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), + 'username' => $session->handle, + 'display_name' => data_get($profile, 'displayName', $session->handle), + 'access_token' => $session->accessToken, + 'refresh_token' => $session->refreshToken, 'token_expires_at' => now()->addHours(2), 'meta' => [ 'service' => $service, @@ -130,12 +135,14 @@ protected function accountValues(array $identity, ?SocialAccount $reconnect): ar ->timeout(30) ->get(config('trypost.platforms.bluesky.default_service').'/xrpc/'.BlueskyLexicon::GET_SESSION); - if (! $response->successful() || $response->json('did') !== data_get($identity, 'platform_user_id')) { + $session = BlueskySession::fromResponse($response); + + if ($session === null || $session->did !== data_get($identity, 'platform_user_id')) { throw new ConnectFlowException('error_connecting', $this->platform); } - if ($response->json('emailConfirmed') !== true) { - throw new ConnectFlowException($response->json('emailConfirmed') === false + if ($session->emailConfirmed !== true) { + throw new ConnectFlowException($session->emailConfirmed === false ? 'bluesky_email_unconfirmed' : 'error_connecting', $this->platform); } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 81ecdccff..54ed6ca77 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -5,6 +5,7 @@ namespace App\Services\Social; use App\Actions\SocialAccount\SyncXSubscription; +use App\Dto\BlueskySession; use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\BlueskyPublishException; @@ -24,6 +25,7 @@ use App\Services\Social\Telegram\TelegramApi; use App\Support\GoogleBusinessResourceName; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -341,14 +343,7 @@ private function refreshBlueskyToken(SocialAccount $account): void $response = $client->send(fn () => $this->refreshHttp()->withToken($account->refresh_token) ->post("{$service}/xrpc/".BlueskyLexicon::REFRESH_SESSION)); - $data = $response->json(); - $account->update([ - 'access_token' => $this->tokenFrom($data, $account->platform, 'accessJwt'), - 'refresh_token' => $this->tokenFrom($data, $account->platform, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - $account->refresh(); + $this->updateBlueskySession($account, $response); return; } catch (TokenExpiredException) { @@ -362,14 +357,7 @@ private function refreshBlueskyToken(SocialAccount $account): void 'password' => decrypt($account->meta['password']), ])); - $data = $reauth->json(); - $account->update([ - 'access_token' => $this->tokenFrom($data, $account->platform, 'accessJwt'), - 'refresh_token' => $this->tokenFrom($data, $account->platform, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - $account->refresh(); + $this->updateBlueskySession($account, $reauth); return; } catch (TokenExpiredException) { @@ -380,6 +368,23 @@ private function refreshBlueskyToken(SocialAccount $account): void throw new TokenExpiredException('Bluesky session expired'); } + private function updateBlueskySession(SocialAccount $account, Response $response): void + { + $session = BlueskySession::fromResponse($response); + + if ($session === null || ! $session->hasTokens() || $session->did !== $account->platform_user_id) { + throw new PlatformUnavailableException('Bluesky returned an invalid session response.'); + } + + $account->update([ + 'access_token' => $session->accessToken, + 'refresh_token' => $session->refreshToken, + 'token_expires_at' => now()->addHours(2), + ]); + + $account->refresh(); + } + private function refreshYouTubeToken(SocialAccount $account): void { if (! $account->refresh_token) { diff --git a/tests/Browser/ChannelPostingGoalTest.php b/tests/Browser/ChannelPostingGoalTest.php index 6f11dd673..92210a075 100644 --- a/tests/Browser/ChannelPostingGoalTest.php +++ b/tests/Browser/ChannelPostingGoalTest.php @@ -351,11 +351,17 @@ function fakeBlueskyIdentity(string $did): void Http::fake([ "{$service}/xrpc/com.atproto.server.createSession" => Http::response([ + 'emailConfirmed' => true, 'did' => $did, 'handle' => 'goal.bsky.social', 'accessJwt' => 'access-token', 'refreshJwt' => 'refresh-token', ]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response([ + 'did' => $did, + 'handle' => 'goal.bsky.social', + 'emailConfirmed' => true, + ]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => $did, 'handle' => 'goal.bsky.social', diff --git a/tests/Browser/ConnectConfirmationTest.php b/tests/Browser/ConnectConfirmationTest.php index 371a6a767..0b13cb341 100644 --- a/tests/Browser/ConnectConfirmationTest.php +++ b/tests/Browser/ConnectConfirmationTest.php @@ -390,7 +390,7 @@ function connectConfirmPath(string $route, mixed ...$parameters): string 'emailConfirmed' => true, 'did' => 'did:plc:confirm', 'handle' => 'confirm.bsky.social', 'accessJwt' => 'access', 'refreshJwt' => 'refresh', ]), - "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:confirm', 'emailConfirmed' => true]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:confirm', 'handle' => 'confirm.bsky.social', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response(['displayName' => 'Confirm Bluesky']), ]); diff --git a/tests/Browser/ConnectSwitchAccountTest.php b/tests/Browser/ConnectSwitchAccountTest.php index 89770a70c..11ca6d47e 100644 --- a/tests/Browser/ConnectSwitchAccountTest.php +++ b/tests/Browser/ConnectSwitchAccountTest.php @@ -164,7 +164,7 @@ function switchAccountPath(string $route, mixed ...$parameters): string 'accessJwt' => 'access-token', 'refreshJwt' => 'refresh-token', ]), - "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:first', 'emailConfirmed' => true]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:first', 'handle' => 'first.bsky.social', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:first', 'handle' => 'first.bsky.social', diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 2cff872a3..b22aeee3e 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -563,6 +563,7 @@ $account = SocialAccount::factory()->create([ 'workspace_id' => Workspace::factory()->create()->id, 'platform' => $platform, + 'platform_user_id' => 'did:plc:refresh-test', 'status' => Status::Connected, 'refresh_token' => 'rt-seed', 'meta' => ['service' => 'https://bsky.social', 'identifier' => 'a.bsky.social'], @@ -571,6 +572,7 @@ Http::fake(['*' => Http::response([ 'access_token' => 'at', 'refresh_token' => 'rt', 'expires_in' => 3600, 'accessJwt' => 'j', 'refreshJwt' => 'r', 'id' => '1', 'data' => ['id' => '1'], + 'did' => $account->platform_user_id, 'handle' => 'a.bsky.social', ], 200)]); try { diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index d8f273995..6ec1f3aa7 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -105,6 +105,8 @@ test('refreshes bluesky token before verifying when expired', function () { Http::fake([ config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.server.refreshSession' => Http::response([ + 'did' => 'did:plc:123', + 'handle' => 'test.bsky.social', 'accessJwt' => 'new_access_token', 'refreshJwt' => 'new_refresh_token', ], 200), @@ -112,6 +114,7 @@ ]); $account = SocialAccount::factory()->bluesky()->create([ + 'platform_user_id' => 'did:plc:123', 'token_expires_at' => now()->subHour(), 'refresh_token' => 'old_refresh_token', ]); @@ -125,6 +128,73 @@ Http::assertSent(fn ($request) => str_contains($request->url(), 'refreshSession')); }); +test('bluesky refresh and reauthentication accept typed sessions without requiring a confirmed email', function (bool $reauthenticate) { + $account = SocialAccount::factory()->bluesky()->create([ + 'platform_user_id' => 'did:plc:account', + 'access_token' => 'old-access', + 'refresh_token' => 'old-refresh', + 'meta' => ['identifier' => 'account.bsky.social', 'password' => encrypt('app-password')], + ]); + $body = [ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'accessJwt' => 'new-access', + 'refreshJwt' => 'new-refresh', + 'emailConfirmed' => false, + ]; + $service = config('trypost.platforms.bluesky.default_service'); + + Http::fake([ + "{$service}/xrpc/com.atproto.server.refreshSession" => $reauthenticate + ? Http::response(['error' => 'ExpiredToken'], 401) + : Http::response($body), + "{$service}/xrpc/com.atproto.server.createSession" => Http::response($body), + ]); + + expect((new ConnectionVerifier)->refreshToken($account))->toBeTrue() + ->and($account->fresh()->access_token)->toBe('new-access') + ->and($account->fresh()->refresh_token)->toBe('new-refresh') + ->and($account->fresh()->status)->toBe(Status::Connected); + + Http::assertSentCount($reauthenticate ? 2 : 1); +})->with(['refresh' => false, 'reauthentication' => true]); + +test('bluesky preserves stored tokens when a renewed session is malformed or belongs to another account', function (array $overrides, bool $reauthenticate) { + $account = SocialAccount::factory()->bluesky()->create([ + 'platform_user_id' => 'did:plc:account', + 'access_token' => 'old-access', + 'refresh_token' => 'old-refresh', + 'meta' => ['identifier' => 'account.bsky.social', 'password' => encrypt('app-password')], + ]); + $body = [ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'accessJwt' => 'new-access', + 'refreshJwt' => 'new-refresh', + ...$overrides, + ]; + $service = config('trypost.platforms.bluesky.default_service'); + + Http::fake([ + "{$service}/xrpc/com.atproto.server.refreshSession" => $reauthenticate + ? Http::response(['error' => 'ExpiredToken'], 401) + : Http::response($body), + "{$service}/xrpc/com.atproto.server.createSession" => Http::response($body), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account))->toThrow(PlatformUnavailableException::class) + ->and($account->fresh()->access_token)->toBe('old-access') + ->and($account->fresh()->refresh_token)->toBe('old-refresh') + ->and($account->fresh()->status)->toBe(Status::Connected); + + Http::assertSentCount($reauthenticate ? 2 : 1); +})->with([ + 'missing token' => [['refreshJwt' => null]], + 'malformed token' => [['accessJwt' => []]], + 'other identity' => [['did' => 'did:plc:other']], + 'missing identity' => [['did' => null]], +])->with(['refresh' => false, 'reauthentication' => true]); + test('refreshes youtube token before verifying when expired', function () { Http::fake([ config('trypost.platforms.youtube.oauth_api').'/token' => Http::response([ diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php index 2c0dc163b..1bd0af7ea 100644 --- a/tests/Feature/Social/BlueskyControllerTest.php +++ b/tests/Feature/Social/BlueskyControllerTest.php @@ -34,7 +34,7 @@ 'accessJwt' => 'test-access-token', 'refreshJwt' => 'test-refresh-token', ], 200), - 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:testuser123', 'emailConfirmed' => true]), + 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -144,6 +144,7 @@ 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response(['displayName' => 'Test User']), 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response([ 'did' => 'did:plc:testuser123', + 'handle' => 'testuser.bsky.social', 'emailConfirmed' => false, ]), ]); @@ -169,6 +170,36 @@ && $request->hasHeader('Authorization', 'Bearer fresh-access')); })->with(['new connection' => false, 'reconnect' => true]); +test('bluesky refuses malformed login sessions before offering an identity', function (string $field, mixed $value) { + $body = [ + 'did' => 'did:plc:testuser123', + 'handle' => 'testuser.bsky.social', + 'accessJwt' => 'access-token', + 'refreshJwt' => 'refresh-token', + 'emailConfirmed' => true, + $field => $value, + ]; + + Http::fake(['https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response($body)]); + + $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [ + 'identifier' => 'testuser.bsky.social', + 'password' => 'xxxx-xxxx-xxxx-xxxx', + ])->assertRedirect(route('app.social.connect.show', Platform::Bluesky)); + + expect(PendingConnection::current()->failure())->toBe('error_connecting') + ->and(PendingConnection::current()->isReady())->toBeFalse() + ->and(session('social_connect.identities'))->toBeNull() + ->and($this->workspace->socialAccounts()->count())->toBe(0); + + Http::assertSentCount(1); +})->with([ + 'missing did' => ['did', null], + 'missing handle' => ['handle', null], + 'missing access token' => ['accessJwt', null], + 'malformed refresh token' => ['refreshJwt', []], +]); + test('bluesky refuses an older pending connection when session verification cannot confirm the account', function (array $body, int $status) { startSocialConnect($this->workspace, Platform::Bluesky)->offer([ PendingConnection::identity(Platform::Bluesky, 'did:plc:testuser123', 'Test User', 'testuser.bsky.social', null, 'profile', [ @@ -186,9 +217,9 @@ ->and(session('social_connect.identities'))->toBeNull() ->and($this->workspace->socialAccounts()->count())->toBe(0); })->with([ - 'unconfirmed email' => [['did' => 'did:plc:testuser123', 'emailConfirmed' => false], 200], - 'missing confirmation' => [['did' => 'did:plc:testuser123'], 200], - 'different identity' => [['did' => 'did:plc:other', 'emailConfirmed' => true], 200], + 'unconfirmed email' => [['did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', 'emailConfirmed' => false], 200], + 'missing confirmation' => [['did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social'], 200], + 'different identity' => [['did' => 'did:plc:other', 'handle' => 'other.bsky.social', 'emailConfirmed' => true], 200], 'expired token' => [['error' => 'ExpiredToken'], 401], 'provider unavailable' => [[], 503], ]); @@ -234,7 +265,7 @@ 'accessJwt' => 'test-access-token', 'refreshJwt' => 'test-refresh-token', ], 200), - 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:newuser456', 'emailConfirmed' => true]), + 'https://bsky.social/xrpc/com.atproto.server.getSession' => Http::response(['did' => 'did:plc:newuser456', 'handle' => 'newuser.bsky.social', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:newuser456', 'handle' => 'newuser.bsky.social', @@ -284,7 +315,7 @@ 'accessJwt' => 'fresh-access-token', 'refreshJwt' => 'fresh-refresh-token', ], 200), - "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:testuser123', 'emailConfirmed' => true]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -327,7 +358,7 @@ 'accessJwt' => 'other-access-token', 'refreshJwt' => 'other-refresh-token', ], 200), - "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:someoneelse999', 'emailConfirmed' => true]), + "{$service}/xrpc/com.atproto.server.getSession" => Http::response(['did' => 'did:plc:someoneelse999', 'handle' => 'someone-else.bsky.social', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:someoneelse999', 'handle' => 'someone-else.bsky.social', diff --git a/tests/Unit/Dto/BlueskySessionTest.php b/tests/Unit/Dto/BlueskySessionTest.php new file mode 100644 index 000000000..8083e9703 --- /dev/null +++ b/tests/Unit/Dto/BlueskySessionTest.php @@ -0,0 +1,104 @@ + Http::response([ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'accessJwt' => 'access-token', + 'refreshJwt' => 'refresh-token', + 'emailConfirmed' => $emailConfirmed, + ])])->post('https://bsky.social/xrpc/com.atproto.server.createSession'); + + $session = BlueskySession::fromResponse($response); + + expect($session)->toBeInstanceOf(BlueskySession::class) + ->and($session->did)->toBe('did:plc:account') + ->and($session->handle)->toBe('account.bsky.social') + ->and($session->accessToken)->toBe('access-token') + ->and($session->refreshToken)->toBe('refresh-token') + ->and($session->emailConfirmed)->toBe($emailConfirmed) + ->and($session->hasTokens())->toBeTrue(); + + expect($session->__debugInfo())->toBe([ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'emailConfirmed' => $emailConfirmed, + ]); +})->with([true, false, null]); + +test('bluesky session accepts a session lookup without tokens or email confirmation', function () { + $response = Http::fake(['*' => Http::response([ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + ])])->get('https://bsky.social/xrpc/com.atproto.server.getSession'); + + $session = BlueskySession::fromResponse($response); + + expect($session->accessToken)->toBeNull() + ->and($session->refreshToken)->toBeNull() + ->and($session->emailConfirmed)->toBeNull() + ->and($session->hasTokens())->toBeFalse(); +}); + +test('bluesky session rejects malformed fields instead of casting them', function (string $field, mixed $value) { + $body = [ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'accessJwt' => 'access-token', + 'refreshJwt' => 'refresh-token', + 'emailConfirmed' => true, + $field => $value, + ]; + + $response = Http::fake(['*' => Http::response($body)]) + ->post('https://bsky.social/xrpc/com.atproto.server.createSession'); + + expect(BlueskySession::fromResponse($response))->toBeNull(); +})->with([ + 'string true' => ['emailConfirmed', 'true'], + 'string false' => ['emailConfirmed', 'false'], + 'blank boolean' => ['emailConfirmed', ''], + 'whitespace boolean' => ['emailConfirmed', ' '], + 'numeric boolean' => ['emailConfirmed', 1], + 'string numeric boolean' => ['emailConfirmed', '1'], + 'array boolean' => ['emailConfirmed', []], + 'null did' => ['did', null], + 'blank did' => ['did', ' '], + 'array did' => ['did', []], + 'numeric handle' => ['handle', 123], + 'blank handle' => ['handle', ''], + 'null access token' => ['accessJwt', null], + 'array access token' => ['accessJwt', []], + 'blank access token' => ['accessJwt', ' '], + 'boolean refresh token' => ['refreshJwt', true], + 'blank refresh token' => ['refreshJwt', ''], +]); + +test('bluesky session rejects incomplete or unsuccessful responses', function (array|string $body, int $status) { + $response = Http::fake(['*' => Http::response($body, $status)]) + ->get('https://bsky.social/xrpc/com.atproto.server.getSession'); + + expect(BlueskySession::fromResponse($response))->toBeNull(); +})->with([ + 'missing identity' => [[], 200], + 'missing handle' => [['did' => 'did:plc:account'], 200], + 'missing did' => [['handle' => 'account.bsky.social'], 200], + 'invalid json' => ['Bad Gateway', 200], + 'scalar json' => ['true', 200], + 'unsuccessful response' => [['did' => 'did:plc:account', 'handle' => 'account.bsky.social', 'emailConfirmed' => true], 401], +]); + +test('bluesky session does not consider a partial token pair authenticated', function () { + $response = Http::fake(['*' => Http::response([ + 'did' => 'did:plc:account', + 'handle' => 'account.bsky.social', + 'accessJwt' => 'access-token', + ])])->post('https://bsky.social/xrpc/com.atproto.server.createSession'); + + expect(BlueskySession::fromResponse($response)->hasTokens())->toBeFalse(); +}); From c1cc49bacdd6a933a04e23b5fbb8ba6097a3ada4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 17:17:12 -0300 Subject: [PATCH 4/4] Remove unnecessary Bluesky session debug method --- app/Dto/BlueskySession.php | 12 ------------ tests/Unit/Dto/BlueskySessionTest.php | 5 ----- 2 files changed, 17 deletions(-) diff --git a/app/Dto/BlueskySession.php b/app/Dto/BlueskySession.php index 8ff21b365..56b9037ce 100644 --- a/app/Dto/BlueskySession.php +++ b/app/Dto/BlueskySession.php @@ -57,16 +57,4 @@ public function hasTokens(): bool { return $this->accessToken !== null && $this->refreshToken !== null; } - - /** - * @return array{did: string, handle: string, emailConfirmed: ?bool} - */ - public function __debugInfo(): array - { - return [ - 'did' => $this->did, - 'handle' => $this->handle, - 'emailConfirmed' => $this->emailConfirmed, - ]; - } } diff --git a/tests/Unit/Dto/BlueskySessionTest.php b/tests/Unit/Dto/BlueskySessionTest.php index 8083e9703..820a94200 100644 --- a/tests/Unit/Dto/BlueskySessionTest.php +++ b/tests/Unit/Dto/BlueskySessionTest.php @@ -24,11 +24,6 @@ ->and($session->emailConfirmed)->toBe($emailConfirmed) ->and($session->hasTokens())->toBeTrue(); - expect($session->__debugInfo())->toBe([ - 'did' => 'did:plc:account', - 'handle' => 'account.bsky.social', - 'emailConfirmed' => $emailConfirmed, - ]); })->with([true, false, null]); test('bluesky session accepts a session lookup without tokens or email confirmation', function () {