diff --git a/.ai/rules/auth.md b/.ai/rules/auth.md index a7a4334183..b8398c33f4 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 9bca2c64b3..718a3634c4 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/Dto/BlueskySession.php b/app/Dto/BlueskySession.php new file mode 100644 index 0000000000..56b9037ce1 --- /dev/null +++ b/app/Dto/BlueskySession.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/app/Exceptions/Social/BlueskyPublishException.php b/app/Exceptions/Social/BlueskyPublishException.php index 164afedffc..928adfd415 100644 --- a/app/Exceptions/Social/BlueskyPublishException.php +++ b/app/Exceptions/Social/BlueskyPublishException.php @@ -19,6 +19,15 @@ public static function fromApiResponse(mixed $response): static $error = data_get($body, 'error', ''); $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); + if (self::isEmailUnconfirmed($response)) { + return (new static( + userMessage: 'Confirm your email in Bluesky settings, then try publishing again.', + category: ErrorCategory::Permission, + platformErrorCode: 'unconfirmed_email', + rawResponse: $rawResponse, + ))->asNetworkRejection(); + } + if (self::isConfirmedDeadToken($response)) { throw new TokenExpiredException( message: $errorMessage, @@ -75,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/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index d7e225f5d5..b74d772257 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -4,7 +4,10 @@ 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; use App\Services\Social\BlueskyLexicon; use App\Support\Social\PendingConnection; use Exception; @@ -51,6 +54,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, [ @@ -67,33 +74,39 @@ public function store(Request $request): RedirectResponse throw ValidationException::withMessages(['password' => __('accounts.bluesky.invalid_credentials')]); } - $data = $response->json(); + $session = BlueskySession::fromResponse($response); + + 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() : []; - if (PendingConnection::current()?->platform() !== $this->platform) { - $this->rememberConnectSession($request, $workspace); - } - 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, @@ -114,4 +127,26 @@ 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); + + $session = BlueskySession::fromResponse($response); + + if ($session === null || $session->did !== data_get($identity, 'platform_user_id')) { + throw new ConnectFlowException('error_connecting', $this->platform); + } + + if ($session->emailConfirmed !== true) { + throw new ConnectFlowException($session->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 95d461fe4a..0540b06652 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -127,7 +127,7 @@ public function finish(FinishSocialConnectionRequest $request): RedirectResponse try { $accounts = $this->connectIdentities($workspace, $pending->selected($request->validated('identities')), $reconnect); - } 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/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 5f57709317..6e436edcab 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 e7e04628ae..caa4bef604 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 { @@ -510,6 +512,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 +558,10 @@ 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) { + if (BlueskyPublishException::isEmailUnconfirmed($response)) { + throw BlueskyPublishException::fromApiResponse($response); + } + Log::error('Bluesky video upload failed', [ 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 81ecdccff5..54ed6ca77e 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/lang/ar/accounts.php b/lang/ar/accounts.php index 6b7161aee7..39eb84706b 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 187d3411e0..cc84b33421 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 1a905fd727..4957225bab 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 ac5dd94cca..45181f1a8c 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 a74daa00bf..b18daf7a9f 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 be587ae7a6..dd7d493d77 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 c43876c1fe..8291998755 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 b787fde68a..2c9abb051d 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 540bdfea35..609f9d0d64 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 9a42f366b7..be7861198c 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 c258b3e9b6..a4ff4125be 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 e67ab1d48a..3f6f7d3323 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 d961e6d296..ceaf3f000f 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 875564a6b0..9d82088de9 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 d69783042b..0fce3c21da 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 d5d36886cd..90ca4705a4 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/ChannelPostingGoalTest.php b/tests/Browser/ChannelPostingGoalTest.php index 6f11dd673e..92210a0750 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 7747d3cd8d..0b13cb3417 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', '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 12da87c2ca..11ca6d47e8 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', '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/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index ad21559d18..b0dec1b131 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/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 2cff872a36..b22aeee3eb 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/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 9886fc39f4..348b43bd58 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,59 @@ 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 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/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index d8f2739955..6ec1f3aa7a 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 14d289bef4..1bd0af7ea8 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', 'handle' => 'testuser.bsky.social', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -79,6 +83,173 @@ ]); }); +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', + 'handle' => 'testuser.bsky.social', + '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 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', [ + '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', '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], +]); + +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('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([ @@ -88,11 +259,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', 'handle' => 'newuser.bsky.social', 'emailConfirmed' => true]), 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ 'did' => 'did:plc:newuser456', 'handle' => 'newuser.bsky.social', @@ -136,11 +309,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', 'handle' => 'testuser.bsky.social', 'emailConfirmed' => true]), "{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([ 'did' => 'did:plc:testuser123', 'handle' => 'testuser.bsky.social', @@ -177,11 +352,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', '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 0000000000..820a942005 --- /dev/null +++ b/tests/Unit/Dto/BlueskySessionTest.php @@ -0,0 +1,99 @@ + 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(); + +})->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(); +}); diff --git a/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php b/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php index e76631f152..ccae59121f 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');