347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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/);
|
||
|
|
});
|
||
|
|
});
|