test: check that pairing, the policy Bearer check and the SSO hand-off answer 503 while the shared store is down #1066

This commit is contained in:
Linus Rath
2026-11-01 00:03:20 +01:00
parent 47ec029717
commit 10abf7273c
4 changed files with 89 additions and 1 deletions
@@ -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);
});
});
+20
View File
@@ -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);
});
});
@@ -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();
});
});
+27 -1
View File
@@ -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<string, unknown> = {}) {
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/);
});
});