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>
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|