diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index d8bae72..63bc98d 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -27,9 +27,9 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { eq, sql } from 'drizzle-orm'; +import { eq, sql, and, isNull } from 'drizzle-orm'; import { db } from '../db/client.js'; -import { users, appConfig } from '../db/schema.js'; +import { users, appConfig, memberCredentials } from '../db/schema.js'; import { isSetupLocked } from '../lib/setupGuard.js'; import { COLOR_PALETTE } from '../auth/user.js'; import { @@ -352,6 +352,21 @@ setupRouter.post('/complete', async (c) => { const locked = await isSetupLocked(); if (locked) return c.json({ error: 'Setup already complete' }, 423); + // IN-02: Guard against skipping the credential step — require an unclaimed user + // with an associated credential before locking setup. Without this check an operator + // could call /complete directly, producing a state where setup_complete=true but no + // admin user exists: first OIDC login creates a non-admin with no credential. + const [unclaimedWithCred] = await db + .select({ id: users.id }) + .from(users) + .innerJoin(memberCredentials, eq(memberCredentials.userId, users.id)) + .where(and(isNull(users.oidcIss), eq(users.claimed, false))) + .limit(1); + + if (!unclaimedWithCred) { + return c.json({ error: 'Cannot lock setup: no credential configured' }, 422); + } + await db .insert(appConfig) .values({ key: 'setup_complete', value: 'true' }) diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts index a1fc093..bce06f1 100644 --- a/apps/api/tests/routes/setup.test.ts +++ b/apps/api/tests/routes/setup.test.ts @@ -471,14 +471,31 @@ describe('POST /api/setup/credential', () => { 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