/** * Isolated Stage-2 admin API smoke (creates + deletes its own users). * Usage: npx tsx scripts/validate-admin-stage2.ts */ import { UserRole } from '@prisma/client'; import { createApp } from '../src/app.ts'; import { prisma } from '../src/lib/prisma.ts'; import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.ts'; const app = createApp(); const stamp = Date.now(); const adminEmail = `stage2-admin-${stamp}@example.com`; const targetEmail = `stage2-target-${stamp}@example.com`; const password = 'password12'; const createdIds: bigint[] = []; async function request(path: string, init: RequestInit = {}) { const headers = new Headers(init.headers); if (init.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const res = await app.fetch(new Request(`http://localhost${path}`, { ...init, headers })); const raw = await res.text(); let body: Record = {}; try { body = JSON.parse(raw) as Record; } catch { body = {}; } return { status: res.status, body, raw }; } function assert(cond: unknown, message: string): asserts cond { if (!cond) throw new Error(message); } try { const regTarget = await request('/auth/register', { method: 'POST', body: JSON.stringify({ email: targetEmail, password }), }); assert(regTarget.status === 201, `target register failed: ${regTarget.status}`); const targetExt = ((regTarget.body.data as { user: { id: string } }).user.id); const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetExt } }); createdIds.push(targetUser.id); const regAdmin = await request('/auth/register', { method: 'POST', body: JSON.stringify({ email: adminEmail, password }), }); assert(regAdmin.status === 201, `admin register failed: ${regAdmin.status}`); const adminExt = ((regAdmin.body.data as { user: { id: string } }).user.id); const adminUser = await prisma.user.findUniqueOrThrow({ where: { externalId: adminExt } }); createdIds.push(adminUser.id); await prisma.user.update({ where: { id: adminUser.id }, data: { role: UserRole.admin } }); const login = await request('/auth/login', { method: 'POST', body: JSON.stringify({ email: adminEmail, password }), }); assert(login.status === 200, `admin login failed: ${login.status}`); const token = (login.body.data as { sessionToken: string }).sessionToken; assert(token && !/passwordHash|tokenHash/i.test(login.raw), 'login leaked secrets'); const auth = { Authorization: `Bearer ${token}` }; const unauth = await request('/admin/stats'); assert(unauth.status === 401, `expected 401, got ${unauth.status}`); const stats = await request('/admin/stats', { headers: auth }); assert(stats.status === 200, `stats expected 200, got ${stats.status}`); assert(!/passwordHash|tokenHash|sessionToken/i.test(stats.raw), 'stats leaked secrets'); const statsData = stats.body.data as Record; assert(typeof statsData.totalUsers === 'number', 'missing totalUsers'); const users = await request('/admin/users?page=1&pageSize=5&sortBy=createdAt&sortDir=desc', { headers: auth, }); assert(users.status === 200, `users expected 200, got ${users.status}`); assert(!/passwordHash|tokenHash/i.test(users.raw), 'users leaked secrets'); const byEmail = await request(`/admin/users?q=${encodeURIComponent(targetEmail)}`, { headers: auth, }); assert(byEmail.status === 200, 'email search failed'); const emailItems = (byEmail.body.data as { items: Array<{ id: string; email: string | null }> }) .items; assert( emailItems.some((i) => i.email === targetEmail), 'email search missed target', ); const byId = await request(`/admin/users?q=${encodeURIComponent(targetExt)}`, { headers: auth }); assert(byId.status === 200, 'externalId search failed'); const idItems = (byId.body.data as { items: Array<{ id: string }> }).items; assert(idItems.some((i) => i.id === targetExt), 'externalId search missed target'); const page2 = await request('/admin/users?page=2&pageSize=1&sortBy=role&sortDir=asc', { headers: auth, }); assert(page2.status === 200, 'pagination/sort failed'); const badSort = await request('/admin/users?sortBy=notAWhitelistedField', { headers: auth }); assert(badSort.status === 422, `invalid sort expected 422, got ${badSort.status}`); const relogin = await request('/auth/login', { method: 'POST', body: JSON.stringify({ email: adminEmail, password }), }); assert(relogin.status === 200, 're-login failed'); const token2 = (relogin.body.data as { sessionToken: string; user: { role: string } }).sessionToken; const role2 = (relogin.body.data as { user: { role: string } }).user.role; assert(role2 === 'admin', 're-login role is not admin'); const stats2 = await request('/admin/stats', { headers: { Authorization: `Bearer ${token2}` } }); assert(stats2.status === 200, 'stats after re-login failed'); const auditStats = await prisma.auditLog.count({ where: { actorUserId: adminUser.id, action: AUDIT_ACTIONS.ADMIN_VIEW_STATS }, }); const auditUsers = await prisma.auditLog.count({ where: { actorUserId: adminUser.id, action: AUDIT_ACTIONS.ADMIN_VIEW_USERS }, }); assert(auditStats >= 2, `expected admin_view_stats audits, got ${auditStats}`); assert(auditUsers >= 1, `expected admin_view_users audits, got ${auditUsers}`); console.log( JSON.stringify( { ok: true, database: process.env.DATABASE_URL?.replace(/:[^:@/]+@/, ':***@'), adminEmail, targetEmail, checks: [ '401 unauthenticated', '200 stats', '200 users', 'email search', 'externalId search', 'pagination+sort', '422 invalid sort', 're-login + stats', 'audit logs', 'no secret leakage', ], auditStats, auditUsers, totalUsersFromStats: statsData.totalUsers, }, null, 2, ), ); } catch (err) { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; } finally { if (createdIds.length > 0) { await prisma.auditLog.deleteMany({ where: { OR: [{ actorUserId: { in: createdIds } }, { targetUserId: { in: createdIds } }], }, }); await prisma.user.deleteMany({ where: { id: { in: createdIds } } }); } await prisma.$disconnect(); }