From 10abf7273ce5ec93ef690bd2fa15569aae3374f1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:03:20 +0100 Subject: [PATCH] test: check that pairing, the policy Bearer check and the SSO hand-off answer 503 while the shared store is down #1066 --- .../__tests__/policy-bearer-route.test.ts | 21 ++++++++++++++ lib/__tests__/pair-create-route.test.ts | 20 +++++++++++++ .../pair-reauth-sso-complete.test.ts | 21 ++++++++++++++ lib/__tests__/sso-complete-route.test.ts | 28 ++++++++++++++++++- 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/app/api/admin/__tests__/policy-bearer-route.test.ts b/app/api/admin/__tests__/policy-bearer-route.test.ts index 4bf99a360..e02680b81 100644 --- a/app/api/admin/__tests__/policy-bearer-route.test.ts +++ b/app/api/admin/__tests__/policy-bearer-route.test.ts @@ -43,6 +43,9 @@ vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() })); import { GET } from '@/app/api/admin/policy/route'; import { resetBearerIdentityCacheForTests } from '@/lib/auth/bearer-identity'; +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from '@/lib/__tests__/helpers/shared-store-down'; const SERVER = 'https://mail.example.org'; const GOOD_TOKEN = 'good-token-abc'; @@ -194,3 +197,21 @@ describe('GET /api/admin/policy with a Bearer token', () => { expect(JSON.stringify(logs.calls)).not.toContain(GOOD_TOKEN); }); }); + +describe('GET /api/admin/policy with a Bearer token while the shared store is down (#1066)', () => { + afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + }); + + it('answers 503: the refused-token budget can not be counted, so the token is not checked', async () => { + const store = switchableSharedStore(); + store.down = true; + setStoreForTests(store); + const res = await get({ authorization: `Bearer ${GOOD_TOKEN}` }); + expect(res.status).toBe(503); + expect(fetchMock).not.toHaveBeenCalled(); + // Without a token the public subset needs no store. + expect((await get()).status).toBe(200); + }); +}); diff --git a/lib/__tests__/pair-create-route.test.ts b/lib/__tests__/pair-create-route.test.ts index 02ff95081..e048d8ca1 100644 --- a/lib/__tests__/pair-create-route.test.ts +++ b/lib/__tests__/pair-create-route.test.ts @@ -59,6 +59,9 @@ import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle'; import { POST as createPOST } from '@/app/api/auth/pair/create/route'; import { POST as redeemPOST } from '@/app/api/auth/pair/redeem/route'; import { GET as statusGET } from '@/app/api/auth/pair/status/route'; +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from './helpers/shared-store-down'; const SERVER = 'https://mail.example.org'; const CUSTOM_SERVER = 'https://custom.example'; @@ -852,3 +855,20 @@ describe('pair/create for a server the app refuses', () => { expect((await create({ password: PASSWORD })).status).toBe(200); }); }); + +describe('pair/create while the shared store is down (#1066)', () => { + afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + }); + + it('answers 503 to a password step-up and sends nothing to the mail server', async () => { + const store = switchableSharedStore(); + store.down = true; + setStoreForTests(store); + const res = await create({ password: PASSWORD }); + expect(res.status).toBe(503); + expect(res.body.error).toBe('temporarily_unavailable'); + expect(calls).toHaveLength(0); + }); +}); diff --git a/lib/__tests__/pair-reauth-sso-complete.test.ts b/lib/__tests__/pair-reauth-sso-complete.test.ts index c214f8095..052c3269e 100644 --- a/lib/__tests__/pair-reauth-sso-complete.test.ts +++ b/lib/__tests__/pair-reauth-sso-complete.test.ts @@ -62,6 +62,9 @@ import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle'; import { POST as completePOST } from '@/app/api/auth/reauth/sso/complete/route'; import { POST as createPOST } from '@/app/api/auth/pair/create/route'; import { POST as redeemPOST } from '@/app/api/auth/pair/redeem/route'; +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from './helpers/shared-store-down'; const SERVER = 'https://mail.example.org'; const WEBMAIL = 'https://webmail.example'; @@ -338,3 +341,21 @@ describe('reauth/sso/complete freshness', () => { expect((await complete()).status).toBe(200); }); }); + +describe('reauth/sso/complete while the shared store is down (#1066)', () => { + afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + }); + + it('answers 503 and sets no pairing proof when the grant can not be kept', async () => { + const store = switchableSharedStore(); + store.down = true; + setStoreForTests(store); + signIn(2); + pending({ slot: 2 }); + const res = await complete(); + expect(res.status).toBe(503); + expect(readPairReauthFromStore(cookieStore as never)).toBeNull(); + }); +}); diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index 73090fb2f..22b508fd3 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; // POST /api/auth/sso/complete. A pairing step-up (purpose `reauth`) @@ -39,6 +39,9 @@ import { openPhoneRefreshToken } from '@/lib/auth/pair-bundle'; import { POST } from '@/app/api/auth/sso/complete/route'; import { createHash } from 'crypto'; import { confirmHandoff, redeemHandoff, resetHandoffStoreForTests } from '@/lib/auth/handoff-store'; +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from './helpers/shared-store-down'; function pending(extra: Record = {}) { jar.set('sso_pending', encryptPayload({ @@ -198,3 +201,26 @@ describe('/api/auth/sso/complete for an app that sent a PKCE challenge', () => { expect(res.body.mobile_handoff_code).toBeUndefined(); }); }); + +describe('sso/complete mobile hand-off while the shared store is down (#1066)', () => { + afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + }); + + it('answers 503 instead of a hand-off code that could never be redeemed', async () => { + const store = switchableSharedStore(); + store.down = true; + setStoreForTests(store); + tokenEndpoint = 'https://mail.example.org/auth/token'; + pending({ + redirect_uri: 'https://webmail.example/mail/en/auth/callback', + mobile_redirect_uri: 'bulwarkmobile://auth/callback', + mobile_state: 'm-1', + mobile_code_challenge: createHash('sha256').update('x'.repeat(50)).digest('base64url'), + }); + const res = await complete(); + expect(res.status).toBe(503); + expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|mobile_handoff_code/); + }); +});