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,338 @@
|
||||
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<string, number>;
|
||||
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<Record<string, unknown>>;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user