diff --git a/src/user-store.ts b/src/user-store.ts index c26586fe..a3ab9404 100644 --- a/src/user-store.ts +++ b/src/user-store.ts @@ -198,6 +198,22 @@ async function writeUsers(users: UserRecord[]): Promise { cache = { users, ts: Date.now() }; } +/** + * Serialize every read-modify-write on users.json. Without this a fire-and-forget + * touchLastLogin (fired on each Basic auth) can interleave with a route's + * create/update and clobber records, since both do readUsers(true) → mutate → + * writeUsers against a single shared file + tmp path. + */ +let mutateChain: Promise = Promise.resolve(); +function withUsersLock(fn: () => Promise): Promise { + const run = mutateChain.then(fn, fn); + mutateChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + export async function hasUsers(): Promise { return (await readUsers()).length > 0; } @@ -259,25 +275,27 @@ export async function createUser(opts: CreateUserOptions): Promise { if (!opts.password || opts.password.length < 8) { throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); } - const users = await readUsers(true); - if (users.some((u) => u.username === username)) { - throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS'); - } - if (users.length >= maxUsers()) { - throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT'); - } - const record: UserRecord = { - username, - role: opts.role, - password: await hashPassword(opts.password), - disabled: false, - mustChangePassword: !!opts.mustChangePassword, - canBypassPermissions: !!opts.canBypassPermissions, - createdAt: Date.now(), - }; - users.push(record); - await writeUsers(users); - return record; + return withUsersLock(async () => { + const users = await readUsers(true); + if (users.some((u) => u.username === username)) { + throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS'); + } + if (users.length >= maxUsers()) { + throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT'); + } + const record: UserRecord = { + username, + role: opts.role, + password: await hashPassword(opts.password), + disabled: false, + mustChangePassword: !!opts.mustChangePassword, + canBypassPermissions: !!opts.canBypassPermissions, + createdAt: Date.now(), + }; + users.push(record); + await writeUsers(users); + return record; + }); } /** Set a user's password. `mustChangePassword` is left unchanged unless specified. */ @@ -290,13 +308,15 @@ export async function setPassword( throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); } const norm = normalizeUsername(username); - const users = await readUsers(true); - const record = users.find((u) => u.username === norm); - if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); - record.password = await hashPassword(password); - if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword; - await writeUsers(users); - return record; + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + record.password = await hashPassword(password); + if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword; + await writeUsers(users); + return record; + }); } export interface UpdateUserPatch { @@ -308,39 +328,48 @@ export interface UpdateUserPatch { export async function updateUser(username: string, patch: UpdateUserPatch): Promise { const norm = normalizeUsername(username); - const users = await readUsers(true); - const record = users.find((u) => u.username === norm); - if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + return withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); - // Guard the last-enabled-admin invariant against demote/disable. - const before = countEnabledAdmins(users); - const projected: UserRecord = { - ...record, - role: patch.role ?? record.role, - disabled: patch.disabled ?? record.disabled, - }; - const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u))); - if (before > 0 && after === 0) { - throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN'); - } + // Guard the last-enabled-admin invariant against demote/disable. + const before = countEnabledAdmins(users); + const projected: UserRecord = { + ...record, + role: patch.role ?? record.role, + disabled: patch.disabled ?? record.disabled, + }; + const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u))); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN'); + } - if (patch.role !== undefined) record.role = patch.role; - if (patch.disabled !== undefined) record.disabled = patch.disabled; - if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions; - if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword; - await writeUsers(users); - return record; + if (patch.role !== undefined) record.role = patch.role; + if (patch.disabled !== undefined) record.disabled = patch.disabled; + if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions; + if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword; + await writeUsers(users); + return record; + }); } -/** Record a successful login timestamp. Best-effort; failures are swallowed. */ +/** + * Record a successful login timestamp. Best-effort + throttled: skips the write if + * the last login was within the last minute (Basic clients re-send credentials on + * every request, so this fires often — the throttle keeps disk churn bounded). + */ export async function touchLastLogin(username: string): Promise { const norm = normalizeUsername(username); try { - const users = await readUsers(true); - const record = users.find((u) => u.username === norm); - if (!record) return; - record.lastLoginAt = Date.now(); - await writeUsers(users); + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) return; + if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return; + record.lastLoginAt = Date.now(); + await writeUsers(users); + }); } catch { /* best-effort */ } @@ -348,16 +377,18 @@ export async function touchLastLogin(username: string): Promise { export async function deleteUser(username: string): Promise { const norm = normalizeUsername(username); - const users = await readUsers(true); - const record = users.find((u) => u.username === norm); - if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); - const before = countEnabledAdmins(users); - const remaining = users.filter((u) => u.username !== norm); - const after = countEnabledAdmins(remaining); - if (before > 0 && after === 0) { - throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN'); - } - await writeUsers(remaining); + await withUsersLock(async () => { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); + const before = countEnabledAdmins(users); + const remaining = users.filter((u) => u.username !== norm); + const after = countEnabledAdmins(remaining); + if (before > 0 && after === 0) { + throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN'); + } + await writeUsers(remaining); + }); } /** diff --git a/src/web/admin-audit.ts b/src/web/admin-audit.ts new file mode 100644 index 00000000..857e4095 --- /dev/null +++ b/src/web/admin-audit.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl). + * + * Every user-management action (create/patch/reset/delete/logout/assign) writes one + * JSON line: timestamp, acting admin, action, target, request IP. Same idiom as + * session-lifecycle.jsonl. Best-effort: a write failure never blocks the action. + */ + +import fs from 'node:fs/promises'; +import { dataPath } from '../config/instance.js'; + +export interface AdminAuditEntry { + ts: number; + admin: string; + action: string; + target?: string; + ip?: string; + detail?: Record; +} + +export async function appendAdminAudit(entry: Omit): Promise { + try { + const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n'; + await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 }); + } catch { + /* best-effort audit; never block the action */ + } +} diff --git a/src/web/public/constants.js b/src/web/public/constants.js index fb81deaf..435fe040 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -481,6 +481,9 @@ const SSE_EVENTS = { DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress', DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete', DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed', + // Multi-user (admin-only / targeted) + ADMIN_USERS_CHANGED: 'admin:usersChanged', + AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/routes/admin-routes.ts b/src/web/routes/admin-routes.ts new file mode 100644 index 00000000..635c49ad --- /dev/null +++ b/src/web/routes/admin-routes.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview Admin user-management routes (multi-user mode only). + * + * All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged + * to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8): + * GET /api/admin/users + * POST /api/admin/users + * PATCH /api/admin/users/:username + * POST /api/admin/users/:username/reset-password + * POST /api/admin/users/:username/logout + * DELETE /api/admin/users/:username + * + * Self-service GET /api/me + POST /api/me/password live in me-routes.ts. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { readdirSync } from 'node:fs'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js'; +import { + createUser, + deleteUser, + deleteUserSpace, + findUser, + generateOneTimePassword, + readUsers, + setPassword, + toPublicUser, + updateUser, + UserStoreError, +} from '../../user-store.js'; +import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js'; +import { appendAdminAudit } from '../admin-audit.js'; +import { SseEvent } from '../sse-events.js'; +import type { AuthPort } from '../ports/auth-port.js'; +import type { SessionPort } from '../ports/session-port.js'; +import type { EventPort } from '../ports/event-port.js'; + +const CreateUserSchema = z.object({ + username: z.string().min(1).max(64), + role: z.enum(['admin', 'user']).default('user'), + password: z.string().min(8).max(1024).optional(), + canBypassPermissions: z.boolean().optional(), +}); +const UpdateUserSchema = z.object({ + role: z.enum(['admin', 'user']).optional(), + disabled: z.boolean().optional(), + canBypassPermissions: z.boolean().optional(), +}); +const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() }); + +/** Map a UserStoreError's code onto the API error code + status. */ +function storeError(reply: FastifyReply, err: unknown): ReturnType { + if (err instanceof UserStoreError) { + const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT; + reply.code( + err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400 + ); + return createErrorResponse(code, err.message); + } + reply.code(500); + return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error'); +} + +export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void { + // Gate: admin routes exist only in multi-user mode, and only for admins. + const gate = (req: FastifyRequest, reply: FastifyReply): boolean => { + if (!isMultiUserMode()) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found')); + return false; + } + return requireAdmin(req, reply); + }; + const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record) => + void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail }); + + // Count a user's live sessions + active cookie sessions + case folders. + const statsFor = (username: string) => { + let liveSessions = 0; + for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++; + let activeSessions = 0; + if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++; + let caseCount = 0; + try { + caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length; + } catch { + /* no cases dir yet */ + } + return { liveSessions, activeSessions, caseCount }; + }; + + app.get('/api/admin/users', async (req, reply) => { + if (!gate(req, reply)) return; + const users = await readUsers(true); + return { + success: true, + data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })), + }; + }); + + app.post('/api/admin/users', async (req, reply) => { + if (!gate(req, reply)) return; + const parsed = CreateUserSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input'); + } + // No password given: generate a one-time password, returned ONCE, force change. + const oneTime = parsed.data.password ? undefined : generateOneTimePassword(); + try { + const user = await createUser({ + username: parsed.data.username, + role: parsed.data.role, + password: parsed.data.password ?? oneTime!, + canBypassPermissions: parsed.data.canBypassPermissions, + mustChangePassword: !parsed.data.password, + }); + audit(req, 'user.create', user.username, { role: user.role }); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } }; + } catch (err) { + return storeError(reply, err); + } + }); + + app.patch('/api/admin/users/:username', async (req, reply) => { + if (!gate(req, reply)) return; + const { username } = req.params as { username: string }; + const parsed = UpdateUserSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input'); + } + try { + const user = await updateUser(username, parsed.data); + // Disabling revokes the user's cookie sessions. + if (parsed.data.disabled) revokeUserSessions(ctx.authSessions, username); + audit(req, 'user.update', user.username, parsed.data); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { success: true, data: { user: toPublicUser(user) } }; + } catch (err) { + return storeError(reply, err); + } + }); + + app.post('/api/admin/users/:username/reset-password', async (req, reply) => { + if (!gate(req, reply)) return; + const { username } = req.params as { username: string }; + if (!(await findUser(username))) { + reply.code(404); + return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user'); + } + const oneTime = generateOneTimePassword(); + try { + await setPassword(username, oneTime, { mustChangePassword: true }); + revokeUserSessions(ctx.authSessions, username); + audit(req, 'user.reset-password', username); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { success: true, data: { oneTimePassword: oneTime } }; + } catch (err) { + return storeError(reply, err); + } + }); + + app.post('/api/admin/users/:username/logout', async (req, reply) => { + if (!gate(req, reply)) return; + const { username } = req.params as { username: string }; + const revoked = revokeUserSessions(ctx.authSessions, username); + audit(req, 'user.logout', username, { revoked }); + return { success: true, data: { revoked } }; + }); + + app.delete('/api/admin/users/:username', async (req, reply) => { + if (!gate(req, reply)) return; + const { username } = req.params as { username: string }; + const parsed = DeleteUserSchema.safeParse(req.body ?? {}); + const deleteSpace = parsed.success ? parsed.data.deleteSpace : false; + try { + // Kill the user's live sessions first (normal kill flow, incl. docker/remote + // teardown), before removing the record. + const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id); + for (const id of owned) { + await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {}); + } + revokeUserSessions(ctx.authSessions, username); + await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND + if (deleteSpace) await deleteUserSpace(username); + audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length }); + ctx.broadcast(SseEvent.AdminUsersChanged, {}); + return { success: true, data: { username, deletedSpace: !!deleteSpace } }; + } catch (err) { + return storeError(reply, err); + } + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 747aea36..5f585cda 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -20,4 +20,5 @@ export { registerOrchestratorRoutes } from './orchestrator-routes.js'; export { registerClipboardRoutes } from './clipboard-routes.js'; export { registerSearchRoutes } from './search-routes.js'; export { registerMeRoutes } from './me-routes.js'; +export { registerAdminRoutes } from './admin-routes.js'; export { registerWsRoutes } from './ws-routes.js'; diff --git a/src/web/server.ts b/src/web/server.ts index 84c0406e..a82e818e 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -158,6 +158,7 @@ import { registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, + registerAdminRoutes, registerWsRoutes, } from './routes/index.js'; import { CronService } from '../cron/cron-service.js'; @@ -906,6 +907,7 @@ export class WebServer extends EventEmitter { registerClipboardRoutes(this.app, ctx); registerSearchRoutes(this.app, ctx); registerMeRoutes(this.app, ctx); + registerAdminRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); // Cron: build the service from the same context, recompute diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index e9945ed4..69b566e7 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -386,6 +386,13 @@ export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const; /** The agent base image build failed. */ export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const; +// ─── Multi-user (admin-only / targeted) ────────────────────────────────────── + +/** The user roster changed (admin-only); the Users panel re-fetches. */ +export const AdminUsersChanged = 'admin:usersChanged' as const; +/** A user must change their password (targeted); the frontend shows the modal. */ +export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const; + // ─── Namespace Re-export ───────────────────────────────────────────────────── /** @@ -576,4 +583,6 @@ export const SseEvent = { DockerImageBuildProgress, DockerImageBuildComplete, DockerImageBuildFailed, + AdminUsersChanged, + AuthPasswordChangeRequired, } as const; diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts new file mode 100644 index 00000000..17484d78 --- /dev/null +++ b/test/admin-routes.test.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Phase 5 admin API tests (live server, port 3173). + * + * Covers the admin user-management endpoints: multi-user gate, requireAdmin, + * create (one-time password), patch + last-admin invariant, reset-password, + * disable-revokes-sessions, and delete (last-admin refusal + delete-space). + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { createUser, invalidateUsersCache } from '../src/user-store.js'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +const PORT = 3173; +const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); +const url = (p: string) => `http://localhost:${PORT}${p}`; +const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' }; +const adminNoBody = { Authorization: basic('root', 'rootpass123') }; +const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' }; + +let server: WebServer; +let dataDir: string; +let spacesDir: string; +const saved: Record = {}; + +beforeAll(async () => { + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-')); + spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-')); + for (const k of [ + 'CODEMAN_DATA_DIR', + 'CODEMAN_USER_SPACES_DIR', + 'CODEMAN_MULTIUSER', + 'CODEMAN_PASSWORD', + 'CODEMAN_USERNAME', + ]) { + saved[k] = process.env[k]; + } + process.env.CODEMAN_DATA_DIR = dataDir; + process.env.CODEMAN_USER_SPACES_DIR = spacesDir; + process.env.CODEMAN_MULTIUSER = '1'; + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + invalidateUsersCache(); + await createUser({ username: 'root', role: 'admin', password: 'rootpass123' }); + await createUser({ username: 'joe', role: 'user', password: 'joepass1234' }); + server = new WebServer(PORT, false, true); + await server.start(); +}); + +afterAll(async () => { + await server?.stop(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + invalidateUsersCache(); + await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe('admin API', () => { + it('rejects a non-admin (403)', async () => { + const res = await fetch(url('/api/admin/users'), { headers: regular }); + expect(res.status).toBe(403); + }); + + it('lists users for an admin', async () => { + const res = await fetch(url('/api/admin/users'), { headers: admin }); + expect(res.status).toBe(200); + const { data } = await res.json(); + expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']); + expect(data[0]).not.toHaveProperty('password'); + }); + + it('creates a user with a one-time password', async () => { + const res = await fetch(url('/api/admin/users'), { + method: 'POST', + headers: admin, + body: JSON.stringify({ username: 'newbie', role: 'user' }), + }); + expect(res.status).toBe(200); + const { data } = await res.json(); + expect(data.oneTimePassword).toBeTypeOf('string'); + expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true }); + }); + + it('toggles canBypassPermissions via PATCH', async () => { + const res = await fetch(url('/api/admin/users/joe'), { + method: 'PATCH', + headers: admin, + body: JSON.stringify({ canBypassPermissions: true }), + }); + expect(res.status).toBe(200); + expect((await res.json()).data.user.canBypassPermissions).toBe(true); + }); + + it('refuses to demote the last admin (409)', async () => { + const res = await fetch(url('/api/admin/users/root'), { + method: 'PATCH', + headers: admin, + body: JSON.stringify({ role: 'user' }), + }); + expect(res.status).toBe(409); + expect((await res.json()).errorCode).toBe('LAST_ADMIN'); + }); + + it('resets a password (one-time) and forces change', async () => { + const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody }); + expect(res.status).toBe(200); + const { data } = await res.json(); + expect(data.oneTimePassword).toBeTypeOf('string'); + // joe must now change password before other actions. + const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } }); + expect(gated.status).toBe(403); + expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED'); + }); + + it('refuses to delete the last admin, deletes a regular user + space', async () => { + const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody }); + expect(del.status).toBe(409); + + await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true }); + const del2 = await fetch(url('/api/admin/users/newbie'), { + method: 'DELETE', + headers: admin, + body: JSON.stringify({ deleteSpace: true }), + }); + expect(del2.status).toBe(200); + await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy(); + }); + + it('404s admin routes in single-user mode', async () => { + // Flip the flag off for one request path check. + process.env.CODEMAN_MULTIUSER = ''; + try { + const res = await fetch(url('/api/admin/users'), { headers: admin }); + expect(res.status).toBe(404); + } finally { + process.env.CODEMAN_MULTIUSER = '1'; + } + }); +});