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>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { after, before, describe, it } from 'node:test';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { AttemptPhase, AttemptStatus, UserRole } from '@prisma/client';
|
|
import { prisma } from '../src/lib/prisma.js';
|
|
import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js';
|
|
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
|
|
import { isReverseScored } from '../src/modules/assessments/scoring/reverseScoring.js';
|
|
import { QUESTION_DIMENSION_MAP } from '../src/modules/assessments/scoring/dimensions.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,
|
|
};
|
|
}
|
|
|
|
async function createFullAttempt(userId: bigint, phase: AttemptPhase, likert = 4) {
|
|
const now = new Date();
|
|
const answers = PCQ_QUESTION_IDS.map((questionId) => {
|
|
const reverse = isReverseScored(questionId);
|
|
const points = reverse ? 7 - likert : likert;
|
|
return {
|
|
questionId,
|
|
rawValue: likert,
|
|
points,
|
|
dimension: QUESTION_DIMENSION_MAP[questionId]!,
|
|
isReverseScored: reverse,
|
|
};
|
|
});
|
|
|
|
const efficacy = answers.slice(0, 6).reduce((s, a) => s + a.points, 0);
|
|
const hope = answers.slice(6, 12).reduce((s, a) => s + a.points, 0);
|
|
const resilience = answers.slice(12, 18).reduce((s, a) => s + a.points, 0);
|
|
const optimism = answers.slice(18, 24).reduce((s, a) => s + a.points, 0);
|
|
const total = efficacy + hope + resilience + optimism;
|
|
|
|
return prisma.assessmentAttempt.create({
|
|
data: {
|
|
userId,
|
|
phase,
|
|
status: AttemptStatus.scored,
|
|
submittedAt: now,
|
|
scoredAt: now,
|
|
answers: { create: answers },
|
|
scores: {
|
|
create: [
|
|
{ metricKey: 'efficacy', rawScore: efficacy },
|
|
{ metricKey: 'hope', rawScore: hope },
|
|
{ metricKey: 'resilience', rawScore: resilience },
|
|
{ metricKey: 'optimism', rawScore: optimism },
|
|
{ metricKey: 'total', rawScore: total },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('admin user assessments', () => {
|
|
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()}/assessments`);
|
|
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 registered = await register(`assess-user-${Date.now()}@example.com`);
|
|
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}/assessments`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 403);
|
|
assert.equal(body.error?.code, 'FORBIDDEN');
|
|
assertNoSecrets(raw);
|
|
});
|
|
|
|
it('returns 422 for invalid externalId', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
const { token } = await promoteAndLogin(`assess-invalid-${Date.now()}@example.com`);
|
|
const { status, body, raw } = await requestJson(app, '/admin/users/not-a-uuid/assessments', {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 422);
|
|
assert.equal(body.error?.code, 'VALIDATION_ERROR');
|
|
assertNoSecrets(raw);
|
|
});
|
|
|
|
it('returns 404 for missing user', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
const { token } = await promoteAndLogin(`assess-missing-${Date.now()}@example.com`);
|
|
const { status, body, raw } = await requestJson(
|
|
app,
|
|
`/admin/users/${randomUUID()}/assessments`,
|
|
{ headers: authHeader(token) },
|
|
);
|
|
assert.equal(status, 404);
|
|
assert.equal(body.error?.code, 'NOT_FOUND');
|
|
assertNoSecrets(raw);
|
|
});
|
|
|
|
it('returns empty phases when user has no attempts', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
const stamp = Date.now();
|
|
const target = await register(`assess-empty-${stamp}@example.com`);
|
|
const targetId = (target.body.data?.user as { id: string }).id;
|
|
const { token } = await promoteAndLogin(`assess-empty-admin-${stamp}@example.com`);
|
|
|
|
const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/assessments`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 200);
|
|
assertNoSecrets(raw);
|
|
const data = body.data as {
|
|
pretest: { exists: boolean; answers: unknown[] };
|
|
posttest: { exists: boolean; answers: unknown[] };
|
|
};
|
|
assert.equal(data.pretest.exists, false);
|
|
assert.equal(data.posttest.exists, false);
|
|
assert.equal(data.pretest.answers.length, 0);
|
|
assert.equal(data.posttest.answers.length, 0);
|
|
});
|
|
|
|
it('returns pretest only, ordered answers, stored scores, reverse flags, and audit', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const stamp = Date.now();
|
|
const targetReg = await register(`assess-pre-${stamp}@example.com`);
|
|
const targetId = (targetReg.body.data?.user as { id: string }).id;
|
|
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
|
|
await createFullAttempt(target.id, AttemptPhase.pre, 4);
|
|
|
|
const { token, userId: adminId } = await promoteAndLogin(
|
|
`assess-pre-admin-${stamp}@example.com`,
|
|
);
|
|
|
|
const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/assessments`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 200);
|
|
assertNoSecrets(raw);
|
|
|
|
const data = body.data as {
|
|
user: { id: string; email: string | null };
|
|
pretest: {
|
|
exists: boolean;
|
|
scores: Record<string, number | null>;
|
|
answers: Array<{
|
|
questionId: string;
|
|
questionNumber: number;
|
|
isReverseScored: boolean;
|
|
points: number;
|
|
rawValue: number;
|
|
}>;
|
|
answerCount: number;
|
|
};
|
|
posttest: { exists: boolean };
|
|
};
|
|
|
|
assert.equal(data.user.id, targetId);
|
|
assert.equal(data.pretest.exists, true);
|
|
assert.equal(data.posttest.exists, false);
|
|
assert.equal(data.pretest.answerCount, 24);
|
|
assert.equal(data.pretest.answers.length, 24);
|
|
assert.equal(data.pretest.answers[0]?.questionId, 'pcq_01');
|
|
assert.equal(data.pretest.answers[0]?.questionNumber, 1);
|
|
assert.equal(data.pretest.answers[23]?.questionId, 'pcq_24');
|
|
assert.equal(data.pretest.answers[23]?.questionNumber, 24);
|
|
|
|
const reverseIds = data.pretest.answers
|
|
.filter((a) => a.isReverseScored)
|
|
.map((a) => a.questionId);
|
|
assert.deepEqual(reverseIds, ['pcq_13', 'pcq_20', 'pcq_23']);
|
|
|
|
const reverseAnswer = data.pretest.answers.find((a) => a.questionId === 'pcq_13');
|
|
assert.equal(reverseAnswer?.rawValue, 4);
|
|
assert.equal(reverseAnswer?.points, 3);
|
|
|
|
assert.equal(data.pretest.scores.total, 93);
|
|
assert.equal(data.pretest.scores.efficacy, 24);
|
|
assert.equal(typeof data.pretest.scores.hope, 'number');
|
|
|
|
assert.equal('passwordHash' in data, false);
|
|
assert.equal('messages' in data, false);
|
|
assert.equal('assignments' in data, false);
|
|
|
|
const audit = await prisma.auditLog.findFirst({
|
|
where: {
|
|
actorUserId: adminId,
|
|
targetUserId: target.id,
|
|
action: AUDIT_ACTIONS.ADMIN_VIEW_ASSESSMENT,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
assert.ok(audit);
|
|
const meta = JSON.stringify(audit.metadata ?? {});
|
|
assert.match(meta, /"view":"assessment"/);
|
|
assert.equal(/passwordHash|tokenHash|rawValue|pcq_01/i.test(meta), false);
|
|
});
|
|
|
|
it('separates pre and post attempts', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const stamp = Date.now();
|
|
const targetReg = await register(`assess-both-${stamp}@example.com`);
|
|
const targetId = (targetReg.body.data?.user as { id: string }).id;
|
|
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
|
|
await createFullAttempt(target.id, AttemptPhase.pre, 3);
|
|
await createFullAttempt(target.id, AttemptPhase.post, 5);
|
|
|
|
const { token } = await promoteAndLogin(`assess-both-admin-${stamp}@example.com`);
|
|
const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/assessments`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 200);
|
|
assertNoSecrets(raw);
|
|
const data = body.data as {
|
|
pretest: { exists: boolean; phase: string; scores: { total: number | null } };
|
|
posttest: { exists: boolean; phase: string; scores: { total: number | null } };
|
|
};
|
|
assert.equal(data.pretest.exists, true);
|
|
assert.equal(data.posttest.exists, true);
|
|
assert.equal(data.pretest.phase, 'pre');
|
|
assert.equal(data.posttest.phase, 'post');
|
|
assert.notEqual(data.pretest.scores.total, data.posttest.scores.total);
|
|
});
|
|
|
|
it('handles attempt without scores without inventing values', async (t) => {
|
|
if (!dbAvailable) {
|
|
t.skip('MySQL is not running');
|
|
return;
|
|
}
|
|
|
|
const stamp = Date.now();
|
|
const targetReg = await register(`assess-noscore-${stamp}@example.com`);
|
|
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.assessmentAttempt.create({
|
|
data: {
|
|
userId: target.id,
|
|
phase: AttemptPhase.pre,
|
|
status: AttemptStatus.submitted,
|
|
submittedAt: now,
|
|
scoredAt: now,
|
|
answers: {
|
|
create: [
|
|
{
|
|
questionId: 'pcq_01',
|
|
rawValue: 2,
|
|
points: 2,
|
|
dimension: 'efficacy',
|
|
isReverseScored: false,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { token } = await promoteAndLogin(`assess-noscore-admin-${stamp}@example.com`);
|
|
const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/assessments`, {
|
|
headers: authHeader(token),
|
|
});
|
|
assert.equal(status, 200);
|
|
assertNoSecrets(raw);
|
|
const pretest = (body.data as { pretest: { scores: unknown; answers: unknown[]; status: string } })
|
|
.pretest;
|
|
assert.equal(pretest.scores, null);
|
|
assert.equal(pretest.answers.length, 1);
|
|
assert.equal(pretest.status, 'submitted');
|
|
});
|
|
});
|