import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { randomUUID } from 'node:crypto'; import { UserRole } from '@prisma/client'; import { prisma } from '../src/lib/prisma.js'; import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js'; import { deriveCurrentStep, derivePermissions } from '../src/modules/journey/journey.service.js'; import { assertNoSecrets, authHeader, createTestApp, isDatabaseAvailable, requestJson, } from './helpers.js'; const app = createTestApp(); const createdUserIds: 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, }; } describe('admin user detail', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (dbAvailable && createdUserIds.length > 0) { await prisma.auditLog.deleteMany({ where: { OR: [ { actorUserId: { in: createdUserIds } }, { targetUserId: { in: createdUserIds } }, ], }, }); await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } }); 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()}`); 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 email = `detail-user-${Date.now()}@example.com`; const registered = await register(email); 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}`, { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('returns 422 for an invalid externalId', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`detail-invalid-${Date.now()}@example.com`); const { status, body, raw } = await requestJson(app, '/admin/users/not-a-uuid', { headers: authHeader(token), }); assert.equal(status, 422); assert.equal(body.error?.code, 'VALIDATION_ERROR'); assertNoSecrets(raw); }); it('returns 404 for a missing user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`detail-missing-${Date.now()}@example.com`); const missingId = randomUUID(); const { status, body, raw } = await requestJson(app, `/admin/users/${missingId}`, { headers: authHeader(token), }); assert.equal(status, 404); assert.equal(body.error?.code, 'NOT_FOUND'); assertNoSecrets(raw); }); it('returns detail for admin with journey alignment, audit, and no secrets', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const targetEmail = `detail-target-${stamp}@example.com`; const targetReg = await register(targetEmail); const targetId = (targetReg.body.data?.user as { id: string }).id; const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } }); const now = new Date(); await prisma.userProfile.create({ data: { userId: target.id, name: `DetailTarget${stamp}`, age: 31 }, }); const journey = await prisma.learningJourney.create({ data: { userId: target.id, preTestCompletedAt: now, trainingStartedAt: now, trainingCompletedAt: now, postTestCompletedAt: null, lastActivityAt: now, currentStep: 'training_completed', }, }); await prisma.sessionProgress.createMany({ data: [ { userId: target.id, sessionOrder: 1, status: 'completed', startedAt: now, completedAt: now, }, { userId: target.id, sessionOrder: 2, status: 'completed', startedAt: now, completedAt: now, }, ], }); const expectedStep = deriveCurrentStep(journey); const expectedPermissions = derivePermissions(journey, { completedSessionCount: 2 }); const { token, userId: adminId } = await promoteAndLogin( `detail-admin-${stamp}@example.com`, ); const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}`, { headers: authHeader(token), }); assert.equal(status, 200); assertNoSecrets(raw); const data = body.data as Record; assert.equal(data.id, targetId); assert.equal(data.email, targetEmail); assert.equal(data.profileComplete, true); assert.equal(data.currentStep, expectedStep); assert.equal(data.pretestCompleted, true); assert.equal(data.posttestCompleted, false); assert.equal(data.trainingStarted, true); assert.equal(data.trainingCompleted, true); assert.equal(data.courseCompleted, false); assert.equal(data.completedSessionCount, 2); assert.equal((data.permissions as { isCompleted: boolean }).isCompleted, expectedPermissions.isCompleted); assert.equal('passwordHash' in data, false); assert.equal('tokenHash' in data, false); assert.equal('sessions' in data, false); assert.equal('answers' in data, false); assert.equal('messages' in data, false); const assignments = data.assignments as { total: number }; assert.equal(typeof assignments.total, 'number'); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, targetUserId: target.id, action: AUDIT_ACTIONS.ADMIN_VIEW_USER, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const metaRaw = JSON.stringify(audit.metadata ?? {}); assert.equal(/passwordHash|tokenHash|sessionToken|content|answers/i.test(metaRaw), false); assert.match(metaRaw, /admin_user_detail/); }); it('handles user without LearningJourney or assignments', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const email = `detail-bare-${stamp}@example.com`; const registered = await register(email); const externalId = (registered.body.data?.user as { id: string }).id; const { token } = await promoteAndLogin(`detail-bare-admin-${stamp}@example.com`); const { status, body, raw } = await requestJson(app, `/admin/users/${externalId}`, { headers: authHeader(token), }); assert.equal(status, 200); assertNoSecrets(raw); const data = body.data as { currentStep: string | null; permissions: unknown; pretestCompleted: boolean; courseCompleted: boolean; completedSessionCount: number; lastActivityAt: string | null; assignments: { total: number }; hasPretestAttempt: boolean; hasPosttestAttempt: boolean; }; assert.equal(data.currentStep, null); assert.equal(data.permissions, null); assert.equal(data.pretestCompleted, false); assert.equal(data.courseCompleted, false); assert.equal(data.completedSessionCount, 0); assert.equal(data.lastActivityAt, null); assert.equal(data.assignments.total, 0); assert.equal(data.hasPretestAttempt, false); assert.equal(data.hasPosttestAttempt, false); }); });