Files
Lucas Berger 1f94dc5eb7 feat(19-05): global-setup local_credentials seed + login.spec.ts + CI harness env
- global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline)
- Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login)
- ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job
- Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks
  in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export)
- Full API suite: 446/446 tests pass; pnpm typecheck: exit 0
2026-06-17 17:21:05 -04:00

703 lines
29 KiB
TypeScript

/**
* 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<void>) => {
// 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<void>) => next(),
}));
// ---------------------------------------------------------------------------
// OIDC auth middleware — skip for pre-auth surface
// ---------------------------------------------------------------------------
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => 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<number> {
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<number> {
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<void> {
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);
});
});