Phase 12: Initial Setup Wizard #22
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user