/** * Setup wizard route tests — Plan 12-02. * * Covers SETUP-01, SETUP-02, SETUP-04 + the 423 guard (Pitfall 8) + D-10 * effective-config branch. * * Architecture: * - Tests import `app` (not setupRouter directly — mirrors admin.test.ts Pitfall 9 pattern) * - Real DB integration against familysync_test (vitest globalSetup provisions it) * - credentialSync.js and broker mocks isolate CalDAV calls * * Route surfaces tested: * GET /api/setup/status * POST /api/setup/config * POST /api/setup/validate/db * POST /api/setup/validate/oidc * POST /api/setup/validate/vapid * POST /api/setup/credential * POST /api/setup/complete * * SETUP-04 / Pitfall 8 — 423 guard: * POST /api/setup/complete twice → first 200, second 423 * POST /api/setup/* when effectively configured (member_credentials + VAPID env) → 423 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { db } from '../../src/db/client.js'; import { users, memberCredentials, appConfig } from '../../src/db/schema.js'; // --------------------------------------------------------------------------- // CalDAV credential validation — mock to avoid live Fastmail calls // --------------------------------------------------------------------------- // We need to control whether validateEncryptAndStoreCredential succeeds or throws. // CredentialValidationError is the typed failure signal. class MockCredentialValidationError extends Error { constructor() { super('Credential validation failed'); this.name = 'CredentialValidationError'; } } let mockValidateCredentialShouldThrow = false; let mockValidateCredentialError: Error | null = null; vi.mock('../../src/broker/credentialSync.js', () => ({ validateEncryptAndStoreCredential: vi.fn().mockImplementation(async () => { if (mockValidateCredentialShouldThrow) { throw mockValidateCredentialError ?? new MockCredentialValidationError(); } // Success: write a mock credential row so the guard's effective-config check works return undefined; }), CredentialValidationError: MockCredentialValidationError, })); // --------------------------------------------------------------------------- // CalDAV client — mock to avoid live network calls // --------------------------------------------------------------------------- vi.mock('../../src/broker/client.js', () => ({ createFastmailClient: vi.fn().mockResolvedValue({ fetchCalendars: vi.fn().mockResolvedValue([]), }), })); // --------------------------------------------------------------------------- // Outbox worker — avoid side-effects during tests // --------------------------------------------------------------------------- vi.mock('../../src/broker/outboxWorker.js', () => ({ loadClientForUser: vi.fn().mockResolvedValue({ fetchCalendars: () => Promise.resolve([]), }), startOutboxWorker: vi.fn(), initOutboxTrigger: vi.fn(), scheduleOutboxDrain: vi.fn(), runOutboxDrain: vi.fn(), __resetDrainState: vi.fn(), stopOutboxTrigger: vi.fn(), triggerTargetedResync: vi.fn(), assembleRruleString: vi.fn(), })); // Mock sync.js to avoid actual CalDAV sync during tests vi.mock('../../src/broker/sync.js', () => ({ syncCalendar: vi.fn().mockResolvedValue(undefined), })); // --------------------------------------------------------------------------- // Dev-auth bypass — simulate unauthenticated requests to pre-auth /api/setup routes // Setup routes are pre-auth: no user injection needed; bypass still needed to avoid // oidcAuthMiddleware redirecting /api/* requests. // --------------------------------------------------------------------------- vi.mock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { // No user injection for setup routes — pre-auth surface await next(); }, // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // --------------------------------------------------------------------------- // OIDC auth middleware — skip for pre-auth surface // --------------------------------------------------------------------------- vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: () => null, })); // --------------------------------------------------------------------------- // Fetch mock for OIDC discovery validation // --------------------------------------------------------------------------- let mockFetchShouldFail = false; let mockFetchStatus = 200; vi.stubGlobal( 'fetch', vi.fn().mockImplementation(async (url: string) => { if (mockFetchShouldFail) { throw new Error('Network error: connection refused'); } return { ok: mockFetchStatus >= 200 && mockFetchStatus < 300, status: mockFetchStatus, json: async () => ({ issuer: url.replace('/.well-known/openid-configuration', '') }), }; }), ); // --------------------------------------------------------------------------- // Request helpers // --------------------------------------------------------------------------- function jsonRequest(method: string, path: string, body?: unknown): Request { return new Request(`http://localhost${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); } // --------------------------------------------------------------------------- // Import `app` lazily (after mocks are registered) — Pitfall 9: must import // `app` not `setupRouter` directly so the pre-auth mount + guard are exercised. // --------------------------------------------------------------------------- async function getApp() { const { app } = await import('../../src/index.js'); return app; } // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- async function seedUser(label: string, isAdmin = false, claimed = true): Promise { const [result] = await db .insert(users) .values({ oidcIss: 'https://auth.test.setup', oidcSub: `sub-${label}-${randomUUID()}`, displayName: `Setup User ${label}`, color: '#4A90D9', isAdmin, claimed, }) .$returningId(); return result.id; } async function seedLocalUser(label: string): Promise { const [result] = await db .insert(users) .values({ oidcIss: null, oidcSub: null, displayName: `Local User ${label}`, color: '#4A90D9', isAdmin: true, claimed: false, }) .$returningId(); return result.id; } async function seedCredential(userId: number): Promise { await db.insert(memberCredentials).values({ userId, encryptedPassword: '{"iv":"test","authTag":"test","ciphertext":"test"}', fastmailEmail: 'test@fastmail.com', providerType: 'caldav', }); } // --------------------------------------------------------------------------- // Env and DB cleanup between tests // --------------------------------------------------------------------------- const VAPID_PUBLIC_KEY = 'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE'; const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY'; beforeEach(async () => { process.env.APP_PASSWORD_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; // Reset mock state to defaults mockValidateCredentialShouldThrow = false; mockValidateCredentialError = null; mockFetchShouldFail = false; mockFetchStatus = 200; // Remove VAPID env for a fresh start (tests that need them set them explicitly) delete process.env.VAPID_PRIVATE_KEY; delete process.env.VAPID_PUBLIC_KEY; delete process.env.VAPID_SUBJECT; }); afterEach(async () => { // Clean up seeded rows between tests await db.delete(memberCredentials); await db.delete(users).where(eq(users.oidcIss, 'https://auth.test.setup')); // Delete local (wizard-created) users with null oidcIss // We identify them by displayName prefix for safety // Use raw delete of unclaimed users (the test-seeded ones) await db.delete(users).where(eq(users.claimed, false)); // Clean up app_config keys set during tests await db.delete(appConfig).where(eq(appConfig.key, 'setup_complete')); await db.delete(appConfig).where(eq(appConfig.key, 'oidc_issuer')); await db.delete(appConfig).where(eq(appConfig.key, 'oidc_client_id')); await db.delete(appConfig).where(eq(appConfig.key, 'vapid_public_key')); await db.delete(appConfig).where(eq(appConfig.key, 'app_external_url')); }); // =========================================================================== // SETUP-01: GET /api/setup/status // =========================================================================== describe('GET /api/setup/status', () => { it('returns { setupComplete: false } when no setup has been run (fresh state)', async () => { const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); expect(res.status).toBe(200); const body = (await res.json()) as { setupComplete: boolean }; expect(body.setupComplete).toBe(false); }); // Gap 3 (backend): status exposes the NON-SECRET DB name as an on-screen referent // for the "database connection verified" row. No DB_HOST/DB_USER/DB_PASSWORD. it('returns the non-secret dbName from process.env.DB_NAME (gap 3)', async () => { process.env.DB_NAME = 'familysync_test'; const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); expect(res.status).toBe(200); const bodyText = await res.text(); const body = JSON.parse(bodyText) as { setupComplete: boolean; dbName?: string | null }; expect(body.dbName).toBe('familysync_test'); // No connection secrets/topology may leak into the status response. const password = process.env.DB_PASSWORD; if (password) { expect(bodyText).not.toContain(password); } }); it('returns { setupComplete: true } after app_config.setup_complete is set', async () => { // Directly set setup_complete in DB (simulates completed setup) await db .insert(appConfig) .values({ key: 'setup_complete', value: 'true' }) .onDuplicateKeyUpdate({ set: { value: 'true' } }); const app = await getApp(); const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); expect(res.status).toBe(200); const body = (await res.json()) as { setupComplete: boolean }; expect(body.setupComplete).toBe(true); }); }); // =========================================================================== // SETUP-01: POST /api/setup/config // =========================================================================== describe('POST /api/setup/config', () => { it('returns 200 and upserts all four config keys into app_config', async () => { const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/config', { oidcIssuer: 'https://auth.example.com', oidcClientId: 'familysync-client', vapidPublicKey: VAPID_PUBLIC_KEY, appExternalUrl: 'https://familysync.example.com', }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); // Verify rows were written to app_config const [issuerRow] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'oidc_issuer')) .limit(1); expect(issuerRow?.value).toBe('https://auth.example.com'); }); it('returns 400 when oidcIssuer is not an https URL', async () => { const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/config', { oidcIssuer: 'http://insecure.example.com', oidcClientId: 'familysync-client', vapidPublicKey: VAPID_PUBLIC_KEY, appExternalUrl: 'https://familysync.example.com', }), ); expect(res.status).toBe(400); }); // IN-01: appExternalUrl must also require https:// — it is injected as OIDC_AUTH_EXTERNAL_URL // (the redirect URI base) and Authelia rejects non-https redirect URIs in production. it('IN-01: returns 400 when appExternalUrl is an http:// URL (must require https)', async () => { const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/config', { oidcIssuer: 'https://auth.example.com', oidcClientId: 'familysync-client', vapidPublicKey: VAPID_PUBLIC_KEY, appExternalUrl: 'http://insecure-app.example.com', }), ); expect(res.status).toBe(400); }); }); // =========================================================================== // SETUP-02: POST /api/setup/validate/db // =========================================================================== describe('POST /api/setup/validate/db', () => { it('returns 200 { ok: true } when DB is reachable (SELECT 1 succeeds)', async () => { const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/db')); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); }); // =========================================================================== // SETUP-02: POST /api/setup/validate/vapid // =========================================================================== describe('POST /api/setup/validate/vapid', () => { it('returns 200 { ok: true } for a valid VAPID key pair from generate-secrets', async () => { process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; // Gap 2: the operator-submitted public key (app_config.vapid_public_key) must equal // process.env.VAPID_PUBLIC_KEY for the happy path. Seed the matching row. await db .insert(appConfig) .values({ key: 'vapid_public_key', value: VAPID_PUBLIC_KEY }) .onDuplicateKeyUpdate({ set: { value: VAPID_PUBLIC_KEY } }); const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid')); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); // Gap 2 (major): a wrong wizard-entered public key (e.g. "BH123") must fail the row. // The env VAPID pair is valid, but the submitted key in app_config does not match // process.env.VAPID_PUBLIC_KEY → 400. The response must NEVER contain VAPID_PRIVATE_KEY (T-12-06). it('returns 400 when submitted vapid_public_key does not match process.env.VAPID_PUBLIC_KEY (gap 2)', async () => { process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; // Operator typed a clearly-wrong key — env pair is still structurally valid. await db .insert(appConfig) .values({ key: 'vapid_public_key', value: 'BH123' }) .onDuplicateKeyUpdate({ set: { value: 'BH123' } }); const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid')); expect(res.status).toBe(400); const bodyText = await res.text(); const body = JSON.parse(bodyText) as { ok: boolean }; expect(body.ok).toBe(false); // T-12-06: VAPID_PRIVATE_KEY must never leak into any response. expect(bodyText).not.toContain(VAPID_PRIVATE_KEY); }); // Gap 2: without an operator-submitted key there is nothing to compare → 400. it('returns 400 when app_config.vapid_public_key row is absent (no submitted key)', async () => { process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; // No vapid_public_key seeded in app_config (cleaned in afterEach). const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid')); expect(res.status).toBe(400); const bodyText = await res.text(); const body = JSON.parse(bodyText) as { ok: boolean }; expect(body.ok).toBe(false); expect(bodyText).not.toContain(VAPID_PRIVATE_KEY); }); it('returns 400 { ok: false } when VAPID env vars are missing', async () => { // VAPID env vars not set (cleared in beforeEach) const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid')); expect(res.status).toBe(400); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(false); }); it('returns 400 for an invalid/truncated VAPID key (structural validation)', async () => { process.env.VAPID_PUBLIC_KEY = 'not-a-valid-vapid-key'; process.env.VAPID_PRIVATE_KEY = 'also-not-valid'; const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/vapid')); expect(res.status).toBe(400); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(false); }); }); // =========================================================================== // SETUP-02: POST /api/setup/validate/oidc // =========================================================================== describe('POST /api/setup/validate/oidc', () => { it('returns 200 { ok: true } when OIDC discovery resolves successfully', async () => { // Write issuer to app_config first (normally config step would do this) await db .insert(appConfig) .values({ key: 'oidc_issuer', value: 'https://auth.example.com' }) .onDuplicateKeyUpdate({ set: { value: 'https://auth.example.com' } }); mockFetchShouldFail = false; mockFetchStatus = 200; const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc')); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); it('returns 400 { ok: false } when the OIDC issuer is unreachable (mocked network failure)', async () => { await db .insert(appConfig) .values({ key: 'oidc_issuer', value: 'https://unreachable.example.com' }) .onDuplicateKeyUpdate({ set: { value: 'https://unreachable.example.com' } }); mockFetchShouldFail = true; const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc')); expect(res.status).toBe(400); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(false); }); it('returns 400 when oidc_issuer is not configured in app_config', async () => { const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/validate/oidc')); expect(res.status).toBe(400); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(false); }); }); // =========================================================================== // SETUP-01: POST /api/setup/credential — CalDAV PROPFIND validation // =========================================================================== describe('POST /api/setup/credential', () => { it('returns 200 and stores credential when PROPFIND succeeds (mocked)', async () => { mockValidateCredentialShouldThrow = false; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/credential', { fastmailEmail: 'operator@fastmail.com', appPassword: 'valid-app-password-abc123', }), ); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); it('returns 400 when PROPFIND fails (bad Fastmail app password)', async () => { mockValidateCredentialShouldThrow = true; mockValidateCredentialError = new MockCredentialValidationError(); const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/credential', { fastmailEmail: 'operator@fastmail.com', appPassword: 'bad-password-xyz', }), ); expect(res.status).toBe(400); const bodyText = await res.text(); // Pitfall 7: submitted password MUST NOT appear in response expect(bodyText).not.toContain('bad-password-xyz'); expect(bodyText).not.toContain('received'); }); it('returns 400 with no echoed password when credential validation fails (Pitfall 7 / no-echo)', async () => { mockValidateCredentialShouldThrow = true; mockValidateCredentialError = new MockCredentialValidationError(); const submittedPassword = 'super-secret-that-must-not-be-echoed-abc456'; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/credential', { fastmailEmail: 'operator@fastmail.com', appPassword: submittedPassword, }), ); expect(res.status).toBe(400); const bodyText = await res.text(); expect(bodyText).not.toContain(submittedPassword); expect(bodyText).not.toContain('received'); expect(bodyText).not.toContain('issues'); const body = JSON.parse(bodyText) as { error: string }; expect(body.error).toBe('Invalid request'); }); // WR-01: TOCTOU guard must ignore OIDC users that happen to have claimed=false // (oidcIss IS NOT NULL). Only wizard bootstrap users (oidcIss IS NULL AND claimed=false) // must trigger the DUPLICATE_UNCLAIMED guard. This prevents a partially-bootstrapped // instance (where an OIDC user somehow exists pre-setup) from permanently blocking // the wizard credential step with 409. it('WR-01: TOCTOU guard ignores claimed=false OIDC users (oidcIss NOT NULL) — credential step still succeeds', async () => { // Seed an OIDC user with claimed=false to simulate the latent bug scenario. // After the WR-01 fix upsertUser always inserts claimed=true for OIDC users, but // this tests that the guard's WHERE clause is narrowed correctly for defense-in-depth. await db.insert(users).values({ oidcIss: 'https://auth.test.setup', oidcSub: `sub-wr01-oidc-claimed-false-${Date.now()}`, displayName: 'OIDC User With claimed=false', color: '#4A90D9', isAdmin: false, claimed: false, // legacy/hypothetical — oidcIss is NOT NULL }); mockValidateCredentialShouldThrow = false; const app = await getApp(); const res = await app.fetch( jsonRequest('POST', '/api/setup/credential', { fastmailEmail: 'operator@fastmail.com', appPassword: 'valid-app-password-wr01', }), ); // Must succeed — the OIDC-with-oidcIss row must NOT block the wizard expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean }; expect(body.ok).toBe(true); }); }); // =========================================================================== // SETUP-04: POST /api/setup/complete — 423 guard (Pitfall 8, must be RED before plan 02) // =========================================================================== describe('POST /api/setup/complete — 423 guard (SETUP-04 / Pitfall 8)', () => { it('returns 200 on first call (fresh setup, wizard not yet locked)', async () => { // IN-02: /complete now requires an unclaimed user + credential before locking. const userId = await seedLocalUser('complete-200'); await seedCredential(userId); const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(res.status).toBe(200); }); it('returns 422 when /complete is called with no credential configured (IN-02 guard)', async () => { // No unclaimed user or credential — /complete must refuse to lock setup. const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(res.status).toBe(422); const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/credential/i); }); // This test is the load-bearing RED test — the 423 must be verified. // Second call must return 423 because setup_complete is set after first call. it('returns 423 on second call — setup already complete, wizard locked (Pitfall 8)', async () => { // Seed prerequisite so first /complete call succeeds (IN-02 guard). const userId = await seedLocalUser('complete-423'); await seedCredential(userId); const app = await getApp(); // First call — should succeed and set setup_complete const first = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(first.status).toBe(200); // Second call — must return 423 (guard re-evaluated per call, D-10) const second = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(second.status).toBe(423); const body = (await second.json()) as { error: string }; expect(body.error).toBeDefined(); }); }); // =========================================================================== // D-10 effective-config branch: any /api/setup/* → 423 when effectively configured // (member_credentials row exists AND VAPID env vars present) // =========================================================================== describe('/api/setup/* — 423 when effectively configured (D-10 effective-config branch)', () => { it('any /api/setup/* route returns 423 when member_credentials row exists AND VAPID env set', async () => { // Seed a user and a credential row (effective-config condition) const userId = await seedUser('effective-config', true); await seedCredential(userId); process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; const app = await getApp(); // The /status route always returns the status; the guard kicks in on mutation // routes. Test a mutation route to confirm 423. const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(completeRes.status).toBe(423); }); it('does NOT return 423 when member_credentials row exists but VAPID env is absent', async () => { // Seed credential but no VAPID env const userId = await seedUser('no-vapid', true); await seedCredential(userId); // VAPID env not set (cleared in beforeEach) const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/complete')); // Not locked — VAPID absent means effective-config condition is false // The complete call may succeed (200) or fail for other reasons, but NOT 423 expect(res.status).not.toBe(423); }); it('does NOT return 423 when VAPID env is set but no member_credentials row exists', async () => { // Set VAPID env but no credential row process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; const app = await getApp(); const res = await app.fetch(jsonRequest('POST', '/api/setup/complete')); // Not locked — no credentials means effective-config condition is false expect(res.status).not.toBe(423); }); // CR-01 regression: reproduces the production lock-out scenario. // With VAPID env PRESENT, after /credential creates an unclaimed user + credential row, // POST /complete must still succeed (200, writes setup_complete). Only AFTER /complete // runs does isSetupLocked() return true (via Check 1 / explicit flag) — so a second // /complete call returns 423. // // The bug was that the effective-config branch (credRow + vapidPresent) fired during // the credential→complete window, blocking /complete with 423 permanently. // beforeEach masks this by clearing VAPID env — so we set it explicitly here. it('CR-01: /complete succeeds when VAPID env is set AND unclaimed wizard user+credential exist (in-progress wizard)', async () => { // Explicitly set VAPID env (do NOT rely on beforeEach clearing it) process.env.VAPID_PRIVATE_KEY = VAPID_PRIVATE_KEY; process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; // Simulate POST /credential: creates unclaimed local user + credential (wizard in-progress) const userId = await seedLocalUser('cr01-regression'); await seedCredential(userId); const app = await getApp(); // /complete must succeed (200) — effective-config lock must NOT fire while wizard in-progress const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(completeRes.status).toBe(200); // Verify setup_complete was written to app_config const [flagRow] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'setup_complete')) .limit(1); expect(flagRow?.value).toBe('true'); // Second /complete must return 423 — explicit setup_complete flag now locks unconditionally const secondRes = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(secondRes.status).toBe(423); }); });