fix(12): IN-02 guard /setup/complete against skipping the credential step
Without a prerequisite check, an operator could call POST /api/setup/complete directly, setting setup_complete=true with no admin user or credential row, leaving no recovery path without manual DB surgery. Add an inner join check for an unclaimed user with an associated credential; return 422 if absent. Update /complete tests to seed the prerequisite for the success path and add an explicit 422 regression test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d9dfe72aab
commit
3babbfa20e
@@ -27,9 +27,9 @@ import { Hono } from 'hono';
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
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 { 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 { isSetupLocked } from '../lib/setupGuard.js';
|
||||||
import { COLOR_PALETTE } from '../auth/user.js';
|
import { COLOR_PALETTE } from '../auth/user.js';
|
||||||
import {
|
import {
|
||||||
@@ -352,6 +352,21 @@ setupRouter.post('/complete', async (c) => {
|
|||||||
const locked = await isSetupLocked();
|
const locked = await isSetupLocked();
|
||||||
if (locked) return c.json({ error: 'Setup already complete' }, 423);
|
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
|
await db
|
||||||
.insert(appConfig)
|
.insert(appConfig)
|
||||||
.values({ key: 'setup_complete', value: 'true' })
|
.values({ key: 'setup_complete', value: 'true' })
|
||||||
|
|||||||
@@ -471,14 +471,31 @@ describe('POST /api/setup/credential', () => {
|
|||||||
|
|
||||||
describe('POST /api/setup/complete — 423 guard (SETUP-04 / Pitfall 8)', () => {
|
describe('POST /api/setup/complete — 423 guard (SETUP-04 / Pitfall 8)', () => {
|
||||||
it('returns 200 on first call (fresh setup, wizard not yet locked)', async () => {
|
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 app = await getApp();
|
||||||
const res = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
||||||
expect(res.status).toBe(200);
|
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.
|
// 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.
|
// 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 () => {
|
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();
|
const app = await getApp();
|
||||||
|
|
||||||
// First call — should succeed and set setup_complete
|
// First call — should succeed and set setup_complete
|
||||||
|
|||||||
Reference in New Issue
Block a user