import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { 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[] = []; 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.findUnique({ where: { externalId } }); assert.ok(user); 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); const token = login.body.data?.sessionToken as string; assert.ok(token); return { token, userId: user.id }; } describe('admin stats and users list', () => { 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('GET /admin/stats returns 401 without session', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { status, raw } = await requestJson(app, '/admin/stats'); assert.equal(status, 401); assertNoSecrets(raw); }); it('GET /admin/users returns 403 for a normal user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `stats-user-${Date.now()}@example.com`; const registered = await register(email); const token = registered.body.data?.sessionToken as string; const { status, body, raw } = await requestJson(app, '/admin/users', { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('admin receives stats with real counts and audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token, userId } = await promoteAndLogin(`stats-admin-${Date.now()}@example.com`); const totalBefore = await prisma.user.count(); const { status, body, raw } = await requestJson(app, '/admin/stats', { headers: authHeader(token), }); assert.equal(status, 200); assert.equal(body.success, true); const data = body.data as Record; assert.equal(typeof data.totalUsers, 'number'); assert.ok(data.totalUsers >= totalBefore); assert.equal(typeof data.pretestCompletedUsers, 'number'); assert.equal(typeof data.posttestCompletedUsers, 'number'); assert.equal(typeof data.trainingInProgressUsers, 'number'); assert.equal(typeof data.trainingCompletedUsers, 'number'); assert.equal(typeof data.courseCompletedUsers, 'number'); assert.equal(typeof data.totalAssessmentAttempts, 'number'); assert.equal(typeof data.totalChatSessions, 'number'); assert.equal(data.courseCompletedUsers, data.posttestCompletedUsers); assertNoSecrets(raw); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: userId, action: AUDIT_ACTIONS.ADMIN_VIEW_STATS }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); assertNoSecrets(JSON.stringify(audit.metadata ?? {})); }); it('admin receives users list with search, pagination, and audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const targetEmail = `list-target-${stamp}@example.com`; const target = await register(targetEmail); const targetId = (target.body.data?.user as { id: string }).id; await prisma.userProfile.create({ data: { userId: (await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } })).id, name: `TargetUser${stamp}`, age: 28, }, }); const { token, userId } = await promoteAndLogin(`list-admin-${stamp}@example.com`); const listed = await requestJson(app, '/admin/users?page=1&pageSize=5&sortBy=createdAt&sortDir=desc', { headers: authHeader(token), }); assert.equal(listed.status, 200); assertNoSecrets(listed.raw); const listData = listed.body.data as { items: Array>; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }; assert.ok(Array.isArray(listData.items)); assert.equal(listData.pagination.page, 1); assert.equal(listData.pagination.pageSize, 5); assert.ok(listData.pagination.total >= 1); for (const item of listData.items) { assert.equal('passwordHash' in item, false); assert.equal('tokenHash' in item, false); assert.ok(typeof item.id === 'string'); assert.ok('email' in item); assert.ok('pretestCompleted' in item); assert.ok('completedSessionCount' in item); } const searched = await requestJson( app, `/admin/users?q=${encodeURIComponent(targetEmail)}&page=1&pageSize=10`, { headers: authHeader(token) }, ); assert.equal(searched.status, 200); const searchItems = (searched.body.data as { items: Array<{ id: string; email: string | null }> }) .items; assert.ok( searchItems.some((item) => item.email === targetEmail), 'email search should return the target user', ); const byExternalId = await requestJson( app, `/admin/users?q=${encodeURIComponent(targetId)}&page=1&pageSize=20`, { headers: authHeader(token) }, ); assert.equal(byExternalId.status, 200); const externalItems = ( byExternalId.body.data as { items: Array<{ id: string; email: string | null }> } ).items; assert.ok( externalItems.some((item) => item.id === targetId), 'externalId search should return the target user', ); for (const sortBy of ['createdAt', 'role', 'externalId', 'lastActivityAt'] as const) { const sorted = await requestJson( app, `/admin/users?page=1&pageSize=5&sortBy=${sortBy}&sortDir=asc`, { headers: authHeader(token) }, ); assert.equal(sorted.status, 200, `allowed sortBy=${sortBy} should succeed`); assertNoSecrets(sorted.raw); } const page2 = await requestJson(app, '/admin/users?page=2&pageSize=1&sortBy=createdAt&sortDir=desc', { headers: authHeader(token), }); assert.equal(page2.status, 200); const page2Data = page2.body.data as { items: unknown[]; pagination: { page: number; pageSize: number }; }; assert.equal(page2Data.pagination.page, 2); assert.equal(page2Data.pagination.pageSize, 1); assert.ok(page2Data.items.length <= 1); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: userId, action: AUDIT_ACTIONS.ADMIN_VIEW_USERS }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const metaRaw = JSON.stringify(audit.metadata ?? {}); assert.equal(/passwordHash/i.test(metaRaw), false); assert.equal(/tokenHash/i.test(metaRaw), false); // Search text itself is not stored in audit metadata. assert.equal(metaRaw.includes(targetEmail), false); }); it('maps journey fields consistently with deriveCurrentStep / permissions', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const email = `journey-map-${stamp}@example.com`; const registered = await register(email); const externalId = (registered.body.data?.user as { id: string }).id; const user = await prisma.user.findUniqueOrThrow({ where: { externalId } }); const now = new Date(); await prisma.learningJourney.create({ data: { userId: user.id, preTestCompletedAt: now, trainingStartedAt: now, trainingCompletedAt: now, postTestCompletedAt: now, lastActivityAt: now, currentStep: 'completed', }, }); await prisma.sessionProgress.createMany({ data: [ { userId: user.id, sessionOrder: 1, status: 'completed', startedAt: now, completedAt: now, }, { userId: user.id, sessionOrder: 2, status: 'completed', startedAt: now, completedAt: now, }, { userId: user.id, sessionOrder: 3, status: 'in_progress', startedAt: now, }, ], }); const { token } = await promoteAndLogin(`journey-admin-${stamp}@example.com`); const { status, body, raw } = await requestJson( app, `/admin/users?q=${encodeURIComponent(email)}&page=1&pageSize=10`, { headers: authHeader(token) }, ); assert.equal(status, 200); assertNoSecrets(raw); const item = ( body.data as { items: Array<{ id: string; currentStep: string | null; trainingCompleted: boolean; courseCompleted: boolean; posttestCompleted: boolean; pretestCompleted: boolean; completedSessionCount: number; lastActivityAt: string | null; }>; } ).items.find((row) => row.id === externalId); assert.ok(item); assert.equal(item.currentStep, 'completed'); assert.equal(item.trainingCompleted, true); assert.equal(item.courseCompleted, true); assert.equal(item.posttestCompleted, true); assert.equal(item.pretestCompleted, true); // Only completed session_progress rows count (in_progress excluded). assert.equal(item.completedSessionCount, 2); assert.ok(item.lastActivityAt); assert.equal(item.courseCompleted, item.posttestCompleted); }); it('rejects invalid sortBy with a controlled validation error', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`sort-admin-${Date.now()}@example.com`); // Use a non-secret invalid field name so assertNoSecrets is not a false positive // (Zod echoes the received value in validation details). const { status, body, raw } = await requestJson(app, '/admin/users?sortBy=notAWhitelistedField', { headers: authHeader(token), }); assert.equal(status, 422); assert.equal(body.error?.code, 'VALIDATION_ERROR'); assertNoSecrets(raw); assert.match(JSON.stringify(body.error?.details ?? {}), /notAWhitelistedField/); }); });