Add read-only Noya admin panel with assignments and UX polish.
Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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<string, unknown> = {};
|
||||
try {
|
||||
body = JSON.parse(raw) as Record<string, unknown>;
|
||||
} 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<string, number>;
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user