import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { randomUUID } from 'node:crypto'; import { AssignmentType, ChatRole, UserAssignmentStatus, UserRole, } from '@prisma/client'; import { prisma } from '../src/lib/prisma.js'; import { sanitizeAuditMetadata } from '../src/modules/admin/audit.service.js'; import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js'; import { assertNoSecrets, authHeader, createTestApp, isDatabaseAvailable, requestJson, } from './helpers.js'; /** * Stage 11 — cross-cutting admin security / integrity checks. * Prefer ownership-in-query, explicit DTOs, and fail-closed 404 for foreign nested ids. */ const app = createTestApp(); const createdUserIds: bigint[] = []; const createdDefinitionIds: bigint[] = []; const SENSITIVE_RE = /passwordHash|tokenHash|\$argon2|draftData|reportText|completionNote|responseData|must-not-leak|secret report|foreign-only/i; async function trackUser(externalId: string | undefined) { if (!externalId) return; const user = await prisma.user.findUnique({ where: { externalId } }); if (user) createdUserIds.push(user.id); } async function register(email: string, password = 'password12') { const result = await requestJson(app, '/auth/register', { method: 'POST', body: JSON.stringify({ email, password }), }); await trackUser((result.body.data?.user as { id?: string } | undefined)?.id); return result; } async function promoteAndLogin(email: string): Promise<{ token: string; userId: bigint }> { const registered = await register(email); assert.equal(registered.status, 201); const externalId = (registered.body.data?.user as { id: string }).id; const user = await prisma.user.findUniqueOrThrow({ where: { externalId } }); await prisma.user.update({ where: { id: user.id }, data: { role: UserRole.admin } }); const login = await requestJson(app, '/auth/login', { method: 'POST', body: JSON.stringify({ email, password: 'password12' }), }); assert.equal(login.status, 200); return { token: login.body.data?.sessionToken as string, userId: user.id, }; } async function ensureCourseSession(order: number) { const existing = await prisma.courseSession.findUnique({ where: { order } }); if (existing) return existing; return prisma.courseSession.create({ data: { sessionKey: `sec-audit-${order}-${Date.now()}`, order, title: `Security Audit Session ${order}`, description: 'security', estimatedDurationMinutes: 10, blocks: [], }, }); } describe('admin security audit (stage 11)', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (!dbAvailable) return; if (createdDefinitionIds.length > 0) { await prisma.userAssignment.deleteMany({ where: { definitionId: { in: createdDefinitionIds } }, }); } if (createdUserIds.length > 0) { await prisma.auditLog.deleteMany({ where: { OR: [ { actorUserId: { in: createdUserIds } }, { targetUserId: { in: createdUserIds } }, ], }, }); await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } if (createdDefinitionIds.length > 0) { await prisma.assignmentDefinition.deleteMany({ where: { id: { in: createdDefinitionIds } }, }); } }); it('sanitizeAuditMetadata blocks assignment private payload keys', () => { const cleaned = sanitizeAuditMetadata({ view: 'assignment_detail', draftData: { goal: 'hidden' }, reportText: 'hidden report', completionNote: 'hidden note', responseData: { x: 1 }, rawValue: 4, systemMessage: 'sys', prompt: 'ai prompt', externalId: '11111111-1111-1111-1111-111111111111', }); const raw = JSON.stringify(cleaned); assert.equal(/hidden|sys|ai prompt/i.test(raw), false); assert.equal(/draftData|reportText|completionNote|responseData|rawValue|systemMessage|prompt/i.test(raw), false); assert.match(raw, /assignment_detail/); assert.match(raw, /11111111-1111-1111-1111-111111111111/); }); it('denies unauthenticated and non-admin access across admin surface', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const admin = await promoteAndLogin(`sec-admin-${stamp}@example.com`); const normal = await register(`sec-user-${stamp}@example.com`); assert.equal(normal.status, 201); const normalToken = normal.body.data?.sessionToken as string; const targetId = (normal.body.data?.user as { id: string }).id; const paths = [ '/admin/ping', '/admin/stats', '/admin/users', `/admin/users/${targetId}`, `/admin/users/${targetId}/overview`, `/admin/users/${targetId}/assessments`, `/admin/users/${targetId}/chat-sessions`, `/admin/users/${targetId}/assignments`, `/admin/users/${targetId}/assignments/export`, ]; for (const path of paths) { const unauth = await requestJson(app, path); assert.equal(unauth.status, 401, `${path} should be 401 without auth`); assert.equal(unauth.body.error?.code, 'UNAUTHORIZED'); assertNoSecrets(unauth.raw); const forbidden = await requestJson(app, path, { headers: authHeader(normalToken), }); assert.equal(forbidden.status, 403, `${path} should be 403 for non-admin`); assert.equal(forbidden.body.error?.code, 'FORBIDDEN'); assertNoSecrets(forbidden.raw); } // Admin still allowed (smoke) — proves middleware order does not break happy path. const ok = await requestJson(app, `/admin/users/${targetId}/overview`, { headers: authHeader(admin.token), }); assert.equal(ok.status, 200); }); it('enforces nested ownership (assignment + chat) and safe empty overview', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const admin = await promoteAndLogin(`sec-idor-admin-${stamp}@example.com`); const targetReg = await register(`sec-idor-target-${stamp}@example.com`); const otherReg = await register(`sec-idor-other-${stamp}@example.com`); assert.equal(targetReg.status, 201); assert.equal(otherReg.status, 201); const targetId = (targetReg.body.data?.user as { id: string }).id; const otherId = (otherReg.body.data?.user as { id: string }).id; const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } }); const otherUser = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } }); await ensureCourseSession(1); const def = await prisma.assignmentDefinition.create({ data: { key: `sec-idor-def-${stamp}`, sessionOrder: 1, title: 'Owned Assignment', description: 'd', instructions: 'i', assignmentType: AssignmentType.ONE_TIME, expectedDurationDays: 1, displayOrder: 1, }, }); createdDefinitionIds.push(def.id); const owned = await prisma.userAssignment.create({ data: { userId: targetUser.id, definitionId: def.id, status: UserAssignmentStatus.ACTIVE, activatedAt: new Date(), draftData: { secretField: 'must-not-leak' }, completionNote: 'private note', }, }); const foreign = await prisma.userAssignment.create({ data: { userId: otherUser.id, definitionId: def.id, status: UserAssignmentStatus.COMPLETED, activatedAt: new Date(), completedAt: new Date(), draftData: { secretField: 'foreign-only' }, completionNote: 'foreign note', }, }); const foreignChat = await prisma.chatSession.create({ data: { userId: otherUser.id, sessionOrder: 2, startedAt: new Date(), messages: { create: [{ role: ChatRole.user, content: 'foreign-only chat body' }], }, }, }); // Empty / incomplete user: overview must not throw. const emptyReg = await register(`sec-empty-${stamp}@example.com`); const emptyId = (emptyReg.body.data?.user as { id: string }).id; const emptyOverview = await requestJson(app, `/admin/users/${emptyId}/overview`, { headers: authHeader(admin.token), }); assert.equal(emptyOverview.status, 200); assert.equal((emptyOverview.body.data?.assignments as { total: number }).total, 0); assert.equal((emptyOverview.body.data?.recentActivities as unknown[]).length, 0); assert.equal(SENSITIVE_RE.test(emptyOverview.raw), false); // Owned assignment detail OK; foreign nested id → 404 (no existence leak). const ownedDetail = await requestJson( app, `/admin/users/${targetId}/assignments/${owned.id.toString()}`, { headers: authHeader(admin.token) }, ); assert.equal(ownedDetail.status, 200); assert.equal(SENSITIVE_RE.test(ownedDetail.raw), false); assert.equal('draftData' in (ownedDetail.body.data?.assignment as object), false); const foreignAssign = await requestJson( app, `/admin/users/${targetId}/assignments/${foreign.id.toString()}`, { headers: authHeader(admin.token) }, ); assert.equal(foreignAssign.status, 404); assert.equal(foreignAssign.body.error?.code, 'NOT_FOUND'); assert.equal(SENSITIVE_RE.test(foreignAssign.raw), false); const foreignSession = await requestJson( app, `/admin/users/${targetId}/chat-sessions/${foreignChat.id.toString()}`, { headers: authHeader(admin.token) }, ); assert.equal(foreignSession.status, 404); assert.equal(foreignSession.body.error?.code, 'NOT_FOUND'); assert.equal(/foreign-only chat/i.test(foreignSession.raw), false); // Search/filter must stay inside target userId scope. const filtered = await requestJson( app, `/admin/users/${targetId}/assignments?search=Owned&status=ACTIVE`, { headers: authHeader(admin.token) }, ); assert.equal(filtered.status, 200); const items = filtered.body.data?.items as Array<{ assignmentId: string }>; assert.equal(items.length, 1); assert.equal(items[0]?.assignmentId, owned.id.toString()); assert.equal( items.some((item) => item.assignmentId === foreign.id.toString()), false, ); assert.equal(SENSITIVE_RE.test(filtered.raw), false); // Invalid whitelist sort rejected. const badSort = await requestJson( app, `/admin/users/${targetId}/assignments?sortBy=passwordHash`, { headers: authHeader(admin.token) }, ); assert.equal(badSort.status, 422); assert.equal(badSort.body.error?.code, 'VALIDATION_ERROR'); // Route order: /overview and /export resolve as dedicated handlers (not :externalId / :assignmentId). const overview = await requestJson(app, `/admin/users/${targetId}/overview`, { headers: authHeader(admin.token), }); assert.equal(overview.status, 200); assert.ok(overview.body.data?.assignments); assert.ok(overview.body.data?.learningProgress); assert.equal(SENSITIVE_RE.test(overview.raw), false); assert.equal(/must-not-leak|foreign-only/i.test(overview.raw), false); const exportRes = await requestJson(app, `/admin/users/${targetId}/assignments/export`, { headers: authHeader(admin.token), }); // Dedicated /export route must win (200 workbook). If it were captured as :assignmentId, // digit-only validation would yield 422 instead. assert.equal(exportRes.status, 200); assert.notEqual(exportRes.body.error?.code, 'VALIDATION_ERROR'); assert.equal(SENSITIVE_RE.test(exportRes.raw), false); const missingUser = await requestJson(app, `/admin/users/${randomUUID()}/overview`, { headers: authHeader(admin.token), }); assert.equal(missingUser.status, 404); // Audit for overview stays minimal (no private payloads). const audit = await prisma.auditLog.findFirst({ where: { actorUserId: admin.userId, action: AUDIT_ACTIONS.ADMIN_VIEW_USER_OVERVIEW, targetId: targetId, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const metaRaw = JSON.stringify(audit.metadata ?? null); assert.equal(/draftData|reportText|must-not-leak|passwordHash/i.test(metaRaw), false); assert.match(metaRaw, /user_overview/); }); });