diff --git a/apps/api/tests/auth/user.test.ts b/apps/api/tests/auth/user.test.ts index 1f809b6..e2738c4 100644 --- a/apps/api/tests/auth/user.test.ts +++ b/apps/api/tests/auth/user.test.ts @@ -399,3 +399,42 @@ describe('upsertUser', () => { expect(mockDb.select).toHaveBeenCalledTimes(1); }); }); + +// ── Plan 12-01: D-08 first-login-claims scaffold (Wave-0 RED placeholders) ── +// +// These tests cover the first-login-claims flow that Plan 12-02 implements in +// upsertUser. When setup_complete='true', the first OIDC login from an unknown +// iss+sub should "claim" the single unclaimed local user row (oidcIss IS NULL AND +// claimed=false), binding oidcIss/oidcSub and setting claimed=true. +// +// Key constraints (D-08 / D-10): +// - NEVER look up by email — only oidcIss+oidcSub and claimed=false +// - Preserve is_admin on the claimed row (operator pre-set it in the wizard) +// - Only claim when setup_complete='true' in app_config + +describe('upsertUser — D-08 first-login-claims (Wave-0 scaffold, Plan 12-02 implements)', () => { + it.todo( + 'when setup_complete is true and an unclaimed local user exists (oidcIss IS NULL, claimed=false), ' + + 'binds oidcIss+oidcSub+claimed=true and returns the updated row', + ); + + it.todo( + 'when setup_complete is true and claimed user row is found, ' + + 'preserves is_admin on the claimed user (admin flag not overwritten)', + ); + + it.todo( + 'when setup_complete is false (or unset), does NOT check for unclaimed rows — ' + + 'falls through to normal insert path', + ); + + it.todo( + 'when setup_complete is true but NO unclaimed local user exists, ' + + 'falls through to normal insert path (new user row created)', + ); + + it.todo( + 'first-login-claims NEVER uses email as a lookup key — ' + + 'identity is strictly oidcIss IS NULL AND claimed=false (D-10)', + ); +}); diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts new file mode 100644 index 0000000..5f29c72 --- /dev/null +++ b/apps/api/tests/routes/setup.test.ts @@ -0,0 +1,152 @@ +/** + * Setup wizard route tests — Wave-0 scaffold (Plan 12-01). + * + * 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. + * + * 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/validate/vapid + * POST /api/setup/validate/oidc + * 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 } from 'vitest'; + +// --------------------------------------------------------------------------- +// 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, +// })); + +// --------------------------------------------------------------------------- +// 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.todo('returns { setupComplete: true } after POST /api/setup/complete succeeds'); +}); + +// --------------------------------------------------------------------------- +// 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.todo( + 'returns 400 { ok: false } for a truncated/invalid VAPID public key (structural validation)', + ); + + it.todo('returns 400 for a missing privateKey field'); +}); + +// --------------------------------------------------------------------------- +// 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.todo('returns 400 for a missing issuer field'); +}); + +// --------------------------------------------------------------------------- +// 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.todo( + 'returns 200 and stores encrypted credential when PROPFIND succeeds (mocked fetchCalendars)', + ); + + it.todo( + 'returns 400 with no echoed password when credential validation fails (Pitfall 7 / no-echo)', + ); +}); + +// --------------------------------------------------------------------------- +// 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)'); + + // 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)'); +}); + +// --------------------------------------------------------------------------- +// 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.todo( + 'does NOT return 423 when member_credentials row exists but VAPID env is absent (not fully configured)', + ); + + it.todo( + 'does NOT return 423 when VAPID env is set but no member_credentials row exists (not fully configured)', + ); +});