From 4748d578e7efa7e1856f674549bc7cfb3fee5b49 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 13:53:53 -0400 Subject: [PATCH] test(12-02): isSetupLocked() real impl + RED-first setup route tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement real isSetupLocked() in setupGuard.ts: reads app_config.setup_complete (returns true if value==='true'); else checks member_credentials row + VAPID env for effective-config branch (D-10) - Re-queries DB fresh every call — no module-level cache (D-10/Pitfall 8) - Convert Wave-0 it.todo() scaffolds into real integration tests (17 tests RED) - RED-first 423 guard test: POST /complete twice → first 200, second 423 (Pitfall 8) - D-10 effective-config tests: 423 when credRow AND VAPID env; NOT 423 otherwise - 2 'does NOT return 423' tests pass (404 ≠ 423); all others RED pending Task 2 router --- apps/api/src/lib/setupGuard.ts | 26 +- apps/api/tests/routes/setup.test.ts | 566 +++++++++++++++++++++++----- 2 files changed, 499 insertions(+), 93 deletions(-) diff --git a/apps/api/src/lib/setupGuard.ts b/apps/api/src/lib/setupGuard.ts index d927e1d..b7ac3d4 100644 --- a/apps/api/src/lib/setupGuard.ts +++ b/apps/api/src/lib/setupGuard.ts @@ -10,12 +10,30 @@ * immediately on the next call — even if two requests arrive within the same * event-loop tick. The per-call freshness pattern mirrors db.select() in health.ts. * - * Wave-0 stub (Plan 01): returns false (setup always appears incomplete). + * Two-branch lock logic (D-10): + * 1. Explicit: app_config.setup_complete === 'true' + * 2. Effective: a member_credentials row exists AND VAPID_PRIVATE_KEY + VAPID_PUBLIC_KEY env set + * * Real implementation (Plan 02): reads app_config.setup_complete + checks * member_credentials + VAPID env (effective-config branch, D-10). */ + +import { db } from '../db/client.js'; +import { appConfig, memberCredentials } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +/** Returns true if the wizard is already locked. Re-evaluated fresh — NEVER cache at module level. */ export async function isSetupLocked(): Promise { - // STUB — Wave-0 placeholder. Real impl: Plan 02. - // Re-evaluated fresh on every call — NEVER cache at module level (D-10). - return false; + // Check 1: explicit setup_complete flag in app_config + const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); + if (flagRow?.value === 'true') return true; + + // Check 2: effective configuration — member_credentials row exists AND VAPID env set (D-10) + const [credRow] = await db.select({ id: memberCredentials.id }).from(memberCredentials).limit(1); + const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY; + return !!credRow && vapidPresent; } diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts index 5f29c72..a1fc093 100644 --- a/apps/api/tests/routes/setup.test.ts +++ b/apps/api/tests/routes/setup.test.ts @@ -1,13 +1,8 @@ /** - * Setup wizard route tests — Wave-0 scaffold (Plan 12-01). + * Setup wizard route tests — Plan 12-02. * - * Covers SETUP-01, SETUP-02, SETUP-03, SETUP-04 + the 423 guard (Pitfall 8) + D-10 - * effective-config branch. All tests are it.todo() placeholders at this stage — - * they are intentionally RED so the happy-path implementation (Plan 02) can be - * built TDD-green against them. - * - * The 423-guard test (SETUP-04) MUST be RED before the happy path is implemented. - * This matches the Wave-0 requirement in 12-RESEARCH.md §Validation Architecture. + * 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) @@ -16,8 +11,10 @@ * * Route surfaces tested: * GET /api/setup/status - * POST /api/setup/validate/vapid + * 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 * @@ -26,127 +23,518 @@ * POST /api/setup/* when effectively configured (member_credentials + VAPID env) → 423 */ -import { describe, it } from 'vitest'; +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'; // --------------------------------------------------------------------------- -// Mocks (mirrors admin.test.ts conventions — must be registered before `app` import) -// --------------------------------------------------------------------------- - // CalDAV credential validation — mock to avoid live Fastmail calls -// vi.mock('../../src/broker/credentialSync.js', () => ({ -// validateEncryptAndStoreCredential: vi.fn(), -// CredentialValidationError: class extends Error {}, -// })); -// -// CalDAV client — mock to avoid live network calls -// vi.mock('../../src/broker/client.js', () => ({ -// createFastmailClient: vi.fn(), -// })); -// -// Outbox worker — avoid side-effects during tests -// vi.mock('../../src/broker/outboxWorker.js', () => ({ -// startOutboxWorker: vi.fn(), -// initOutboxTrigger: vi.fn(), -// scheduleOutboxDrain: vi.fn(), -// runOutboxDrain: vi.fn(), -// __resetDrainState: vi.fn(), -// stopOutboxTrigger: vi.fn(), -// })); -// -// Dev-auth bypass — simulate unauthenticated requests to pre-auth /api/setup routes -// (unlike other routes, setup/* is pre-auth so no user injection needed for most tests) -// vi.mock('../../src/auth/devBypass.js', () => ({ -// devAuthBypass: () => 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, -// })); +// --------------------------------------------------------------------------- + +// 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, +})); // --------------------------------------------------------------------------- -// SETUP-01: GET /api/setup/status +// 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(); + }, +})); + +// --------------------------------------------------------------------------- +// 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.todo('returns { setupComplete: false } when no setup has been run (fresh state)'); + 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); + }); - it.todo('returns { setupComplete: true } after POST /api/setup/complete succeeds'); + 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); + }); +}); + +// =========================================================================== +// 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.todo('returns 200 { ok: true } for a valid VAPID key pair from generate-secrets'); + 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; - it.todo( - 'returns 400 { ok: false } for a truncated/invalid VAPID public key (structural validation)', - ); + 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); + }); - it.todo('returns 400 for a missing privateKey field'); + 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.todo( - 'returns 400 { ok: false } when the OIDC issuer is unreachable (mocked network failure)', - ); + 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' } }); - it.todo('returns 400 for a missing issuer field'); + 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.todo( - 'returns 400 when PROPFIND fails (simulates bad Fastmail app password or network error)', - ); + it('returns 200 and stores credential when PROPFIND succeeds (mocked)', async () => { + mockValidateCredentialShouldThrow = false; - it.todo( - 'returns 200 and stores encrypted credential when PROPFIND succeeds (mocked fetchCalendars)', - ); + 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.todo( - 'returns 400 with no echoed password when credential validation fails (Pitfall 7 / no-echo)', - ); + 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'); + }); }); -// --------------------------------------------------------------------------- +// =========================================================================== // 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.todo('returns 200 on first call (fresh setup, wizard not yet locked)'); + it('returns 200 on first call (fresh setup, wizard not yet locked)', async () => { + const app = await getApp(); + const res = await app.fetch(jsonRequest('POST', '/api/setup/complete')); + expect(res.status).toBe(200); + }); - // This test is the load-bearing RED test — the 423 must be verified before - // the happy-path POST /api/setup/complete is implemented. (SETUP-04 requirement) - it.todo('returns 423 on second call — setup already complete, wizard locked (Pitfall 8)'); + // 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 () => { + 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.todo( - 'any /api/setup/* route returns 423 when member_credentials row exists AND VAPID_PRIVATE_KEY + VAPID_PUBLIC_KEY env are set', - ); + 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; - it.todo( - 'does NOT return 423 when member_credentials row exists but VAPID env is absent (not fully configured)', - ); + const app = await getApp(); + const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); + // status route always returns the status — but 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.todo( - 'does NOT return 423 when VAPID env is set but no member_credentials row exists (not fully configured)', - ); + 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); + }); });