test(12-02): isSetupLocked() real impl + RED-first setup route tests

- 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
This commit is contained in:
Lucas Berger
2026-06-15 13:53:53 -04:00
parent c6d0db0119
commit 4748d578e7
2 changed files with 499 additions and 93 deletions
+22 -4
View File
@@ -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<boolean> {
// 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;
}
+477 -89
View File
@@ -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<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,
// }));
// ---------------------------------------------------------------------------
// 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<void>) => {
// 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<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.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);
});
});