import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { randomUUID } from 'node:crypto'; import { AssignmentType, AttemptPhase, AttemptStatus, SessionProgressStatus, UserAssignmentStatus, UserRole, } from '@prisma/client'; import { prisma } from '../src/lib/prisma.js'; import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js'; import { assertNoSecrets, authHeader, createTestApp, isDatabaseAvailable, requestJson, } from './helpers.js'; const app = createTestApp(); const createdUserIds: bigint[] = []; const createdDefinitionIds: bigint[] = []; 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: `overview-test-${order}-${Date.now()}`, order, title: `Overview Session ${order}`, description: 'overview', estimatedDurationMinutes: 10, blocks: [], }, }); } describe('admin user overview', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (!dbAvailable) return; 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('returns 401 without session', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { status, raw } = await requestJson(app, `/admin/users/${randomUUID()}/overview`); assert.equal(status, 401); assertNoSecrets(raw); }); it('returns 403 for a normal user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const registered = await register(`overview-user-${Date.now()}@example.com`); const token = registered.body.data?.sessionToken as string; const id = (registered.body.data?.user as { id: string }).id; const { status, body, raw } = await requestJson(app, `/admin/users/${id}/overview`, { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('returns 422 for invalid externalId and 404 for missing user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`overview-invalid-${Date.now()}@example.com`); const bad = await requestJson(app, '/admin/users/not-a-uuid/overview', { headers: authHeader(token), }); assert.equal(bad.status, 422); const missing = await requestJson(app, `/admin/users/${randomUUID()}/overview`, { headers: authHeader(token), }); assert.equal(missing.status, 404); }); it('returns empty overview for user without activity and records audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token, userId: adminId } = await promoteAndLogin( `overview-empty-admin-${Date.now()}@example.com`, ); const registered = await register(`overview-empty-target-${Date.now()}@example.com`); const externalId = (registered.body.data?.user as { id: string }).id; const { status, body, raw } = await requestJson(app, `/admin/users/${externalId}/overview`, { headers: authHeader(token), }); assert.equal(status, 200); assertNoSecrets(raw); const data = body.data as { user: { id: string }; assignments: { total: number; reported: number; progressPercent: number | null }; learningProgress: { completedLessons: number; progressPercent: number | null }; assessments: { completedCount: number; overallStatus: string; remainingCount: number }; recentActivities: unknown[]; }; assert.equal(data.user.id, externalId); assert.equal(data.assignments.total, 0); assert.equal(data.assignments.reported, 0); assert.equal(data.assignments.progressPercent, null); assert.equal(data.learningProgress.completedLessons, 0); assert.equal(data.assessments.completedCount, 0); assert.equal(data.assessments.overallStatus, 'none'); assert.equal(data.assessments.remainingCount, 2); assert.equal(data.recentActivities.length, 0); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, action: AUDIT_ACTIONS.ADMIN_VIEW_USER_OVERVIEW, targetId: externalId, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const meta = audit!.metadata as Record; assert.equal(meta.view, 'user_overview'); assert.equal(meta.externalId, externalId); }); it('returns owned overview only and omits sensitive payloads', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const { token } = await promoteAndLogin(`overview-data-admin-${stamp}@example.com`); const targetReg = await register(`overview-data-target-${stamp}@example.com`); const otherReg = await register(`overview-data-other-${stamp}@example.com`); const targetId = (targetReg.body.data?.user as { id: string }).id; const otherId = (otherReg.body.data?.user as { id: string }).id; const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } }); const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } }); await ensureCourseSession(1); await prisma.sessionProgress.create({ data: { userId: target.id, sessionOrder: 1, status: SessionProgressStatus.completed, completedAt: new Date('2026-06-01T10:00:00.000Z'), }, }); await prisma.sessionProgress.create({ data: { userId: other.id, sessionOrder: 1, status: SessionProgressStatus.completed, completedAt: new Date('2026-06-01T11:00:00.000Z'), }, }); await ensureCourseSession(2); const def = await prisma.assignmentDefinition.create({ data: { key: `overview-def-${stamp}`, sessionOrder: 2, title: 'Overview Homework', description: 'd', instructions: 'i', assignmentType: AssignmentType.DAILY, expectedDurationDays: 4, displayOrder: 2, }, }); createdDefinitionIds.push(def.id); await prisma.userAssignment.create({ data: { userId: target.id, definitionId: def.id, status: UserAssignmentStatus.REPORTED, activatedAt: new Date('2026-06-02T10:00:00.000Z'), lastActivityAt: new Date('2026-06-03T10:00:00.000Z'), draftData: { secret: 'nope' }, completionNote: 'private note', }, }); await prisma.learningJourney.upsert({ where: { userId: target.id }, create: { userId: target.id, preTestCompletedAt: new Date('2026-05-20T10:00:00.000Z'), trainingStartedAt: new Date('2026-05-21T10:00:00.000Z'), }, update: { preTestCompletedAt: new Date('2026-05-20T10:00:00.000Z'), trainingStartedAt: new Date('2026-05-21T10:00:00.000Z'), }, }); await prisma.assessmentAttempt.create({ data: { userId: target.id, phase: AttemptPhase.pre, status: AttemptStatus.scored, submittedAt: new Date('2026-05-20T10:00:00.000Z'), scoredAt: new Date('2026-05-20T10:00:00.000Z'), answers: { create: [ { questionId: 'q1', rawValue: 3, points: 3, dimension: 'hope', isReverseScored: false, }, ], }, }, }); const beforeAssignments = await prisma.userAssignment.count({ where: { userId: target.id } }); const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/overview`, { headers: authHeader(token), }); assert.equal(status, 200); assertNoSecrets(raw); assert.equal(!/nope|private note|passwordHash|draftData|reportText/i.test(raw), true); const data = body.data as { assignments: { total: number; reported: number; inProgress: number; progressPercent: number | null; }; learningProgress: { completedLessons: number; currentStep: string | null }; assessments: { completedCount: number; hasPretest: boolean; hasPosttest: boolean; overallStatus: string; }; recentActivities: Array<{ type: string; title: string; relatedId: string | null }>; }; assert.equal(data.assignments.total, 1); assert.equal(data.assignments.reported, 1); assert.equal(data.assignments.inProgress, 1); assert.equal(data.assignments.progressPercent, 0); assert.equal(data.learningProgress.completedLessons, 1); assert.equal(data.learningProgress.currentStep, 'training_in_progress'); assert.equal(data.assessments.completedCount, 1); assert.equal(data.assessments.hasPretest, true); assert.equal(data.assessments.hasPosttest, false); assert.equal(data.assessments.overallStatus, 'partial'); assert.ok(data.recentActivities.length > 0); assert.equal( data.recentActivities.every((item) => !item.title.includes('nope')), true, ); const afterAssignments = await prisma.userAssignment.count({ where: { userId: target.id } }); assert.equal(afterAssignments, beforeAssignments); }); });