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:
ar.badiei
2026-09-21 08:00:24 +03:30
co-authored by Cursor
parent 8016b8f45e
commit c6243caa35
104 changed files with 15654 additions and 415 deletions
+350
View File
@@ -0,0 +1,350 @@
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');
});
});
@@ -0,0 +1,363 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import {
AssignmentDayStatus,
AssignmentType,
UserAssignmentStatus,
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[] = [];
const createdDefinitionIds: 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 ensureCourseSession(order: number, title: string) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) {
return prisma.courseSession.update({
where: { order },
data: { title },
});
}
return prisma.courseSession.create({
data: {
sessionKey: `admin-detail-test-${order}-${Date.now()}`,
order,
title,
description: 'admin assignment detail test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createDefinition(input: {
key: string;
sessionOrder: number;
title: string;
expectedDurationDays?: number | null;
}) {
await ensureCourseSession(input.sessionOrder, `جلسه detail ${input.sessionOrder}`);
const def = await prisma.assignmentDefinition.create({
data: {
key: input.key,
sessionOrder: input.sessionOrder,
title: input.title,
description: 'desc',
instructions: 'instr',
assignmentType: AssignmentType.DAILY,
expectedDurationDays:
input.expectedDurationDays !== undefined ? input.expectedDurationDays : 4,
displayOrder: input.sessionOrder,
},
});
createdDefinitionIds.push(def.id);
return def;
}
describe('admin assignment detail', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
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()}/assignments/1`,
);
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(`detail-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}/assignments/1`, {
headers: authHeader(token),
});
assert.equal(status, 403);
assert.equal(body.error?.code, 'FORBIDDEN');
assertNoSecrets(raw);
});
it('returns 422 for invalid externalId or assignmentId', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`detail-invalid-${Date.now()}@example.com`);
const badUser = await requestJson(app, '/admin/users/not-a-uuid/assignments/1', {
headers: authHeader(token),
});
assert.equal(badUser.status, 422);
const badAssignment = await requestJson(
app,
`/admin/users/${randomUUID()}/assignments/not-numeric`,
{ headers: authHeader(token) },
);
assert.equal(badAssignment.status, 422);
});
it('returns 404 for missing user', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`detail-missing-${Date.now()}@example.com`);
const { status, body } = await requestJson(
app,
`/admin/users/${randomUUID()}/assignments/1`,
{ headers: authHeader(token) },
);
assert.equal(status, 404);
assert.equal(body.error?.code, 'NOT_FOUND');
});
it('returns detail for owned assignment, hides foreign assignment, and records audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token, userId: adminId } = await promoteAndLogin(
`detail-admin-${stamp}@example.com`,
);
const targetReg = await register(`detail-target-${stamp}@example.com`);
const otherReg = await register(`detail-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
const def = await createDefinition({
key: `detail-def-${stamp}`,
sessionOrder: 9,
title: 'جزئیات تمرین',
expectedDurationDays: 4,
});
const otherDef = await createDefinition({
key: `detail-other-def-${stamp}`,
sessionOrder: 10,
title: 'تکلیف دیگری',
});
const activatedAt = new Date('2026-04-01T10:00:00.000Z');
const startedAt = new Date('2026-04-02T10:00:00.000Z');
const ua = await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: def.id,
status: UserAssignmentStatus.IN_PROGRESS,
activatedAt,
startedAt,
lastActivityAt: new Date('2026-04-03T12:00:00.000Z'),
draftData: { secretField: 'must-not-leak' },
completionNote: 'private note',
},
});
await prisma.assignmentDailyEntry.create({
data: {
userAssignmentId: ua.id,
userId: target.id,
entryDate: new Date('2026-04-02'),
dayStatus: AssignmentDayStatus.done,
responseData: { answer: 'private' },
note: 'private entry note',
},
});
await prisma.assignmentReport.create({
data: {
userAssignmentId: ua.id,
userId: target.id,
reportText: 'secret report text',
reportStatus: 'in_progress',
},
});
const foreign = await prisma.userAssignment.create({
data: {
userId: other.id,
definitionId: otherDef.id,
status: UserAssignmentStatus.ACTIVE,
},
});
await prisma.learningJourney.upsert({
where: { userId: target.id },
create: {
userId: target.id,
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
},
update: {
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
},
});
const before = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
const { status, body, raw } = await requestJson(
app,
`/admin/users/${targetId}/assignments/${ua.id.toString()}`,
{ headers: authHeader(token) },
);
assert.equal(status, 200);
assertNoSecrets(raw);
assert.equal(
!/must-not-leak|private note|secret report|passwordHash|draftData|responseData/i.test(raw),
true,
);
const data = body.data as {
user: { id: string };
assignment: {
id: string;
definitionTitle: string;
status: string;
assignedAt: string;
startedAt: string | null;
doneDayCount: number;
expectedDurationDays: number | null;
progressPercent: number | null;
};
session: { order: number; title: string };
learningProgress: {
currentStep: string | null;
totalLessons: number;
completedLessons: number;
};
activity: {
firstActivityAt: string | null;
lastActivityAt: string | null;
completedActivityCount: number;
} | null;
};
assert.equal(data.user.id, targetId);
assert.equal(data.assignment.id, ua.id.toString());
assert.equal(data.assignment.definitionTitle, 'جزئیات تمرین');
assert.equal(data.assignment.status, UserAssignmentStatus.IN_PROGRESS);
assert.equal(data.assignment.assignedAt, activatedAt.toISOString());
assert.equal(data.assignment.startedAt, startedAt.toISOString());
assert.equal(data.assignment.doneDayCount, 1);
assert.equal(data.assignment.expectedDurationDays, 4);
assert.equal(data.assignment.progressPercent, 25);
assert.equal(data.session.order, 9);
assert.ok(data.session.title);
assert.equal(data.learningProgress.currentStep, 'training_in_progress');
assert.ok(data.activity);
assert.equal(data.activity!.completedActivityCount, 1);
assert.ok(data.activity!.firstActivityAt);
const foreignRes = await requestJson(
app,
`/admin/users/${targetId}/assignments/${foreign.id.toString()}`,
{ headers: authHeader(token) },
);
assert.equal(foreignRes.status, 404);
assert.equal(foreignRes.body.error?.code, 'NOT_FOUND');
assert.equal(!/تکلیف دیگری|Other/i.test(foreignRes.raw), true);
const missing = await requestJson(
app,
`/admin/users/${targetId}/assignments/999999999999`,
{ headers: authHeader(token) },
);
assert.equal(missing.status, 404);
const after = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
assert.equal(after.status, before.status);
assert.equal(after.updatedAt.toISOString(), before.updatedAt.toISOString());
assert.deepEqual(after.draftData, before.draftData);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_VIEW_ASSIGNMENT,
targetId: ua.id.toString(),
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = audit!.metadata as Record<string, unknown>;
assert.equal(meta.view, 'assignment_detail');
assert.equal(meta.externalId, targetId);
assert.equal(meta.assignmentId, ua.id.toString());
assert.equal(!JSON.stringify(meta).includes('must-not-leak'), true);
});
});
@@ -0,0 +1,618 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import ExcelJS from 'exceljs';
import {
AssignmentDayStatus,
AssignmentType,
UserAssignmentStatus,
UserRole,
} from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import {
ASSIGNMENTS_EXPORT_CONTENT_TYPE,
ASSIGNMENTS_EXPORT_MAX_ROWS,
ASSIGNMENTS_EXPORT_SHEET_ITEMS,
ASSIGNMENTS_EXPORT_SHEET_SUMMARY,
buildAssignmentsExportFilename,
buildAssignmentsWorkbook,
escapeExcelString,
} from '../src/modules/admin/assignmentsExport.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[] = [];
const createdDefinitionIds: 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 ensureCourseSession(order: number, title: string) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) {
return prisma.courseSession.update({
where: { order },
data: { title },
});
}
return prisma.courseSession.create({
data: {
sessionKey: `admin-export-test-${order}-${Date.now()}`,
order,
title,
description: 'admin export test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createDefinition(input: {
key: string;
sessionOrder: number;
title: string;
assignmentType?: AssignmentType;
expectedDurationDays?: number | null;
}) {
await ensureCourseSession(input.sessionOrder, `جلسه export ${input.sessionOrder}`);
const def = await prisma.assignmentDefinition.create({
data: {
key: input.key,
sessionOrder: input.sessionOrder,
title: input.title,
description: 'desc',
instructions: 'instr',
assignmentType: input.assignmentType ?? AssignmentType.DAILY,
expectedDurationDays:
input.expectedDurationDays !== undefined ? input.expectedDurationDays : 4,
displayOrder: input.sessionOrder,
},
});
createdDefinitionIds.push(def.id);
return def;
}
async function requestBinary(
path: string,
init: RequestInit = {},
): Promise<{
status: number;
headers: Headers;
buffer: Buffer;
textPreview: string;
}> {
const headers = new Headers(init.headers);
const response = await app.fetch(new Request(`http://localhost${path}`, { ...init, headers }));
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
status: response.status,
headers: response.headers,
buffer,
textPreview: buffer.subarray(0, Math.min(buffer.length, 400)).toString('utf8'),
};
}
async function loadWorkbook(buffer: Buffer) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
return workbook;
}
function summaryMap(sheet: ExcelJS.Worksheet): Map<string, string | number | boolean | null> {
const map = new Map<string, string | number | boolean | null>();
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const field = String(row.getCell(1).value ?? '');
const raw = row.getCell(2).value;
if (raw == null) {
map.set(field, null);
return;
}
if (typeof raw === 'object' && 'text' in raw) {
map.set(field, String((raw as { text: string }).text));
return;
}
if (typeof raw === 'object' && 'result' in raw) {
map.set(field, String((raw as { result: unknown }).result));
return;
}
map.set(field, raw as string | number | boolean);
});
return map;
}
describe('admin assignments export', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
it('rejects workbook build when assignment rows exceed the export cap', async () => {
assert.equal(ASSIGNMENTS_EXPORT_MAX_ROWS, 500);
const oversized = {
user: { id: randomUUID(), email: null },
learningProgress: {
currentStep: null,
trainingStarted: false,
trainingCompleted: false,
courseCompleted: false,
totalLessons: 11,
completedLessons: 0,
progressPercent: 0,
},
summary: {
total: 501,
notStarted: 501,
opened: 0,
inProgress: 0,
completed: 0,
pending: 501,
progressPercent: null,
},
items: Array.from({ length: 501 }, (_, index) => ({
assignmentId: String(index),
definitionId: String(index),
definitionKey: `k-${index}`,
definitionTitle: `t-${index}`,
assignmentType: 'ONE_TIME',
programId: null,
programTitle: null,
sessionId: null,
sessionOrder: 1,
sessionTitle: null,
status: UserAssignmentStatus.NOT_STARTED,
assignedAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
lastActivityAt: null,
doneDayCount: 0,
expectedDurationDays: null,
progressPercent: null,
})),
};
await assert.rejects(
() => buildAssignmentsWorkbook(oversized),
(error: unknown) =>
error instanceof Error &&
error.name === 'ValidationError' &&
/Too many assignments/i.test(error.message),
);
});
it('sanitizes export filename and keeps workbook sheet names fixed', async () => {
assert.equal(
buildAssignmentsExportFilename('abc/../x\ny"z'),
'noya-assignments-abc-x-y-z.xlsx',
);
assert.equal(
buildAssignmentsExportFilename('../../../etc/passwd'),
'noya-assignments-etc-passwd.xlsx',
);
const empty = await buildAssignmentsWorkbook({
user: { id: '11111111-1111-1111-1111-111111111111', email: null },
learningProgress: {
currentStep: null,
trainingStarted: false,
trainingCompleted: false,
courseCompleted: false,
totalLessons: 11,
completedLessons: 0,
progressPercent: 0,
},
summary: {
total: 0,
notStarted: 0,
opened: 0,
inProgress: 0,
completed: 0,
pending: 0,
progressPercent: null,
},
items: [],
});
const workbook = await loadWorkbook(empty.buffer);
assert.equal(workbook.worksheets.length, 2);
assert.equal(workbook.worksheets[0]!.name, ASSIGNMENTS_EXPORT_SHEET_SUMMARY);
assert.equal(workbook.worksheets[1]!.name, ASSIGNMENTS_EXPORT_SHEET_ITEMS);
assert.match(empty.filename, /^noya-assignments-[a-zA-Z0-9_-]+\.xlsx$/);
});
it('returns 401 without session', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { status, textPreview } = await requestBinary(
`/admin/users/${randomUUID()}/assignments/export`,
);
assert.equal(status, 401);
assertNoSecrets(textPreview);
});
it('returns 403 for a normal user', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const registered = await register(`export-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, textPreview } = await requestBinary(`/admin/users/${id}/assignments/export`, {
headers: authHeader(token),
});
assert.equal(status, 403);
assertNoSecrets(textPreview);
});
it('returns 422 for invalid externalId', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`export-invalid-${Date.now()}@example.com`);
const { status, textPreview } = await requestBinary('/admin/users/not-a-uuid/assignments/export', {
headers: authHeader(token),
});
assert.equal(status, 422);
assertNoSecrets(textPreview);
});
it('returns 404 for missing user', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`export-missing-${Date.now()}@example.com`);
const { status, textPreview } = await requestBinary(
`/admin/users/${randomUUID()}/assignments/export`,
{ headers: authHeader(token) },
);
assert.equal(status, 404);
assertNoSecrets(textPreview);
});
it('exports empty assignments workbook with headers and audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, userId: adminId } = await promoteAndLogin(
`export-empty-admin-${Date.now()}@example.com`,
);
const registered = await register(`export-empty-target-${Date.now()}@example.com`);
const externalId = (registered.body.data?.user as { id: string }).id;
const beforeCount = await prisma.userAssignment.count({
where: { user: { externalId } },
});
const { status, headers, buffer, textPreview } = await requestBinary(
`/admin/users/${externalId}/assignments/export`,
{ headers: authHeader(token) },
);
assert.equal(status, 200);
assert.equal(headers.get('content-type'), ASSIGNMENTS_EXPORT_CONTENT_TYPE);
const disposition = headers.get('content-disposition') ?? '';
assert.match(disposition, /^attachment; filename="noya-assignments-[a-zA-Z0-9_-]+\.xlsx"$/);
assert.equal(!/[\\/\n\r"]/.test(disposition.replace(/^attachment; filename="|"$/g, '')), true);
assert.ok(buffer.length > 100);
assert.equal(!/passwordHash|tokenHash|draftData|reportText/i.test(textPreview), true);
const workbook = await loadWorkbook(buffer);
assert.ok(workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_SUMMARY));
assert.ok(workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_ITEMS));
const summary = summaryMap(workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_SUMMARY)!);
assert.equal(summary.get('External ID'), externalId);
assert.equal(summary.get('Total Assignments'), 0);
assert.equal(summary.get('Completed Assignments'), 0);
const itemsSheet = workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_ITEMS)!;
assert.equal(itemsSheet.rowCount, 1); // header only
assert.equal(String(itemsSheet.getRow(1).getCell(1).value), 'Assignment ID');
assert.equal(String(itemsSheet.getRow(1).getCell(16).value), 'Progress Percent');
const afterCount = await prisma.userAssignment.count({
where: { user: { externalId } },
});
assert.equal(afterCount, beforeCount);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_EXPORT_ASSIGNMENTS,
targetId: externalId,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = audit!.metadata as Record<string, unknown>;
assert.equal(meta.view, 'assignments_export');
assert.equal(meta.format, 'xlsx');
assert.equal(meta.rowCount, 0);
assert.equal(meta.sheetCount, 2);
assert.equal(meta.externalId, externalId);
const metaJson = JSON.stringify(meta);
assert.equal(!/draftData|reportText|password|workbook/i.test(metaJson), true);
});
it('exports only target user rows with safe values and formula injection protection', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`export-list-admin-${stamp}@example.com`);
const targetReg = await register(`export-list-target-${stamp}@example.com`);
const otherReg = await register(`export-list-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const otherUser = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
const dangerousTitle = '=1+2';
const defA = await createDefinition({
key: `export-a-${stamp}`,
sessionOrder: 6,
title: dangerousTitle,
expectedDurationDays: 4,
});
const defB = await createDefinition({
key: `+cmd-${stamp}`,
sessionOrder: 7,
title: 'Safe title',
assignmentType: AssignmentType.ONE_TIME,
expectedDurationDays: null,
});
const defOther = await createDefinition({
key: `export-other-${stamp}`,
sessionOrder: 8,
title: 'Other user assignment',
});
const activatedAt = new Date('2026-03-01T10:00:00.000Z');
const ua = await prisma.userAssignment.create({
data: {
userId: targetUser.id,
definitionId: defA.id,
status: UserAssignmentStatus.COMPLETED,
activatedAt,
startedAt: new Date('2026-03-02T10:00:00.000Z'),
completedAt: new Date('2026-03-03T10:00:00.000Z'),
},
});
await prisma.assignmentDailyEntry.create({
data: {
userAssignmentId: ua.id,
userId: targetUser.id,
entryDate: new Date('2026-03-02'),
dayStatus: AssignmentDayStatus.done,
responseData: { secret: 'should-not-export' },
},
});
await prisma.userAssignment.create({
data: {
userId: targetUser.id,
definitionId: defB.id,
status: UserAssignmentStatus.ACTIVE,
activatedAt: new Date('2026-03-04T10:00:00.000Z'),
},
});
await prisma.userAssignment.create({
data: {
userId: otherUser.id,
definitionId: defOther.id,
status: UserAssignmentStatus.IN_PROGRESS,
},
});
await prisma.learningJourney.upsert({
where: { userId: targetUser.id },
create: {
userId: targetUser.id,
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
},
update: {
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
trainingCompletedAt: null,
postTestCompletedAt: null,
},
});
const beforeUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
const { status, headers, buffer, textPreview } = await requestBinary(
`/admin/users/${targetId}/assignments/export?sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
assert.equal(status, 200);
assert.equal(headers.get('content-type'), ASSIGNMENTS_EXPORT_CONTENT_TYPE);
assert.match(
headers.get('content-disposition') ?? '',
/attachment; filename="noya-assignments-/,
);
assert.equal(!/should-not-export|passwordHash|draftData|reportText/i.test(textPreview), true);
const workbook = await loadWorkbook(buffer);
const summary = summaryMap(workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_SUMMARY)!);
assert.equal(summary.get('External ID'), targetId);
assert.equal(summary.get('Total Assignments'), 2);
assert.equal(summary.get('Completed Assignments'), 1);
assert.equal(summary.get('In Progress Assignments'), 1);
assert.equal(summary.get('Current Step'), 'training_in_progress');
assert.equal(summary.get('Training Started'), true);
assert.equal(summary.get('Course Completed'), false);
const itemsSheet = workbook.getWorksheet(ASSIGNMENTS_EXPORT_SHEET_ITEMS)!;
assert.equal(itemsSheet.rowCount, 3); // header + 2 rows
const row1 = itemsSheet.getRow(2);
const row2 = itemsSheet.getRow(3);
const titles = [String(row1.getCell(4).value ?? ''), String(row2.getCell(4).value ?? '')];
assert.ok(titles.includes(escapeExcelString(dangerousTitle)));
assert.ok(titles.includes('Safe title'));
assert.equal(titles.includes('Other user assignment'), false);
const dangerousCell = titles.find((v) => v.includes('1+2'))!;
assert.equal(dangerousCell.startsWith("'"), true);
assert.equal(dangerousCell, `'${dangerousTitle}`);
// ExcelJS must store escaped values as plain text, not formulas.
const titleCell = row1.getCell(4).value;
assert.equal(typeof titleCell === 'object' && titleCell !== null && 'formula' in titleCell, false);
const keys = [String(row1.getCell(3).value ?? ''), String(row2.getCell(3).value ?? '')];
assert.ok(keys.some((k) => k === escapeExcelString(`+cmd-${stamp}`)));
const completedRow = [row1, row2].find(
(row) => String(row.getCell(9).value) === UserAssignmentStatus.COMPLETED,
);
assert.ok(completedRow);
assert.equal(String(completedRow!.getCell(10).value), activatedAt.toISOString());
assert.equal(Number(completedRow!.getCell(14).value), 1);
assert.equal(Number(completedRow!.getCell(15).value), 4);
assert.equal(Number(completedRow!.getCell(16).value), 25);
const activeRow = [row1, row2].find(
(row) => String(row.getCell(9).value) === UserAssignmentStatus.ACTIVE,
);
assert.ok(activeRow);
assert.equal(activeRow!.getCell(16).value ?? '', '');
const afterUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
assert.equal(afterUa.status, beforeUa.status);
assert.equal(afterUa.updatedAt.toISOString(), beforeUa.updatedAt.toISOString());
assert.equal(afterUa.completedAt?.toISOString(), beforeUa.completedAt?.toISOString());
});
it('returns 422 for more than 500 assignments without successful export audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token, userId: adminId } = await promoteAndLogin(
`export-cap-admin-${stamp}@example.com`,
);
const registered = await register(`export-cap-target-${stamp}@example.com`);
const externalId = (registered.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId } });
await ensureCourseSession(1, 'جلسه cap');
const count = ASSIGNMENTS_EXPORT_MAX_ROWS + 1;
await prisma.assignmentDefinition.createMany({
data: Array.from({ length: count }, (_, index) => ({
key: `export-cap-${stamp}-${index}`,
sessionOrder: 1,
title: `Cap ${index}`,
description: 'cap',
instructions: 'cap',
assignmentType: AssignmentType.ONE_TIME,
expectedDurationDays: null,
displayOrder: index,
})),
});
const defs = await prisma.assignmentDefinition.findMany({
where: { key: { startsWith: `export-cap-${stamp}-` } },
select: { id: true },
});
assert.equal(defs.length, count);
createdDefinitionIds.push(...defs.map((row) => row.id));
await prisma.userAssignment.createMany({
data: defs.map((def) => ({
userId: target.id,
definitionId: def.id,
status: UserAssignmentStatus.NOT_STARTED,
})),
});
const auditsBefore = await prisma.auditLog.count({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_EXPORT_ASSIGNMENTS,
targetId: externalId,
},
});
const { status, buffer, textPreview } = await requestBinary(
`/admin/users/${externalId}/assignments/export`,
{ headers: authHeader(token) },
);
assert.equal(status, 422);
assert.ok(buffer.length < 5_000);
assert.equal(!buffer.subarray(0, 2).equals(Buffer.from([0x50, 0x4b])), true); // not a zip/xlsx
assert.match(textPreview, /Too many assignments|VALIDATION_ERROR/i);
const auditsAfter = await prisma.auditLog.count({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_EXPORT_ASSIGNMENTS,
targetId: externalId,
},
});
assert.equal(auditsAfter, auditsBefore);
});
});
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import { AssignmentType, UserAssignmentStatus, UserRole } from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import {
assertNoSecrets,
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
const createdDefinitionIds: 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 ensureCourseSession(order: number, title: string) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) {
return prisma.courseSession.update({
where: { order },
data: { title },
});
}
return prisma.courseSession.create({
data: {
sessionKey: `admin-filter-test-${order}-${Date.now()}`,
order,
title,
description: 'admin filter test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createDefinition(input: {
key: string;
sessionOrder: number;
title: string;
assignmentType: AssignmentType;
sessionTitle: string;
}) {
await ensureCourseSession(input.sessionOrder, input.sessionTitle);
const def = await prisma.assignmentDefinition.create({
data: {
key: input.key,
sessionOrder: input.sessionOrder,
title: input.title,
description: 'desc',
instructions: 'instr',
assignmentType: input.assignmentType,
expectedDurationDays: 3,
displayOrder: input.sessionOrder,
},
});
createdDefinitionIds.push(def.id);
return def;
}
describe('admin assignments filter', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdDefinitionIds.length > 0) {
await prisma.userAssignment.deleteMany({
where: { definitionId: { in: createdDefinitionIds } },
});
}
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
it('rejects invalid filter query values with 422', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`filter-invalid-${Date.now()}@example.com`);
const registered = await register(`filter-invalid-target-${Date.now()}@example.com`);
const id = (registered.body.data?.user as { id: string }).id;
const badStatus = await requestJson(
app,
`/admin/users/${id}/assignments?status=PENDING`,
{ headers: authHeader(token) },
);
assert.equal(badStatus.status, 422);
const badType = await requestJson(
app,
`/admin/users/${id}/assignments?assignmentType=WEEKLY`,
{ headers: authHeader(token) },
);
assert.equal(badType.status, 422);
const badSort = await requestJson(
app,
`/admin/users/${id}/assignments?sortBy=passwordHash`,
{ headers: authHeader(token) },
);
assert.equal(badSort.status, 422);
const badDir = await requestJson(
app,
`/admin/users/${id}/assignments?sortDir=sideways`,
{ headers: authHeader(token) },
);
assert.equal(badDir.status, 422);
const longSearch = 'x'.repeat(101);
const badSearch = await requestJson(
app,
`/admin/users/${id}/assignments?search=${longSearch}`,
{ headers: authHeader(token) },
);
assert.equal(badSearch.status, 422);
});
it('filters and searches only the target user assignments', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`filter-admin-${stamp}@example.com`);
const targetReg = await register(`filter-target-${stamp}@example.com`);
const otherReg = await register(`filter-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
const defJoy = await createDefinition({
key: `filter-joy-${stamp}`,
sessionOrder: 2,
title: 'تمرین شادی ویژه',
assignmentType: AssignmentType.DAILY,
sessionTitle: `جلسه شکرگزاری فیلتر-${stamp}`,
});
const defMeaning = await createDefinition({
key: `filter-meaning-${stamp}`,
sessionOrder: 4,
title: 'تمرین معنا',
assignmentType: AssignmentType.ONE_TIME,
sessionTitle: `جلسه معنایی-${stamp}`,
});
const defOther = await createDefinition({
key: `filter-other-${stamp}`,
sessionOrder: 3,
title: 'تمرین شادی ویژه',
assignmentType: AssignmentType.DAILY,
sessionTitle: `جلسه شکرگزاری فیلتر-${stamp}`,
});
const uaCompleted = await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: defJoy.id,
status: UserAssignmentStatus.COMPLETED,
activatedAt: new Date('2026-05-01T10:00:00.000Z'),
completedAt: new Date('2026-05-03T10:00:00.000Z'),
},
});
const uaActive = await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: defMeaning.id,
status: UserAssignmentStatus.ACTIVE,
activatedAt: new Date('2026-05-02T10:00:00.000Z'),
},
});
await prisma.userAssignment.create({
data: {
userId: other.id,
definitionId: defOther.id,
status: UserAssignmentStatus.COMPLETED,
},
});
const beforeCount = await prisma.userAssignment.count({ where: { userId: target.id } });
const noFilter = await requestJson(app, `/admin/users/${targetId}/assignments`, {
headers: authHeader(token),
});
assert.equal(noFilter.status, 200);
const noFilterItems = (noFilter.body.data as { items: Array<{ assignmentId: string }> })
.items;
assert.equal(noFilterItems.length, 2);
const byStatus = await requestJson(
app,
`/admin/users/${targetId}/assignments?status=COMPLETED`,
{ headers: authHeader(token) },
);
assert.equal(byStatus.status, 200);
const statusItems = (
byStatus.body.data as {
items: Array<{ assignmentId: string; status: string }>;
summary: { total: number; completed: number };
}
).items;
assert.equal(statusItems.length, 1);
assert.equal(statusItems[0]!.assignmentId, uaCompleted.id.toString());
assert.equal(statusItems[0]!.status, UserAssignmentStatus.COMPLETED);
assert.equal(
(byStatus.body.data as { summary: { total: number } }).summary.total,
1,
);
const byType = await requestJson(
app,
`/admin/users/${targetId}/assignments?assignmentType=ONE_TIME`,
{ headers: authHeader(token) },
);
assert.equal(byType.status, 200);
const typeItems = (byType.body.data as { items: Array<{ assignmentId: string }> }).items;
assert.equal(typeItems.length, 1);
assert.equal(typeItems[0]!.assignmentId, uaActive.id.toString());
const combined = await requestJson(
app,
`/admin/users/${targetId}/assignments?status=COMPLETED&assignmentType=DAILY`,
{ headers: authHeader(token) },
);
assert.equal(combined.status, 200);
assert.equal((combined.body.data as { items: unknown[] }).items.length, 1);
const searchTitle = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent('شادی')}`,
{ headers: authHeader(token) },
);
assert.equal(searchTitle.status, 200);
assert.equal((searchTitle.body.data as { items: unknown[] }).items.length, 1);
const searchKey = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(`filter-meaning-${stamp}`)}`,
{ headers: authHeader(token) },
);
assert.equal(searchKey.status, 200);
assert.equal((searchKey.body.data as { items: unknown[] }).items.length, 1);
const searchSession = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(`شکرگزاری فیلتر-${stamp}`)}`,
{ headers: authHeader(token) },
);
assert.equal(searchSession.status, 200);
assert.equal((searchSession.body.data as { items: unknown[] }).items.length, 1);
const searchMiss = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=zzzz-not-found`,
{ headers: authHeader(token) },
);
assert.equal(searchMiss.status, 200);
assert.equal((searchMiss.body.data as { items: unknown[] }).items.length, 0);
const whitespaceSearch = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(' ')}`,
{ headers: authHeader(token) },
);
assert.equal(whitespaceSearch.status, 200);
assert.equal((whitespaceSearch.body.data as { items: unknown[] }).items.length, 2);
const sortAsc = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
const sortDesc = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=desc`,
{ headers: authHeader(token) },
);
const ascOrders = (
sortAsc.body.data as { items: Array<{ sessionOrder: number }> }
).items.map((item) => item.sessionOrder);
const descOrders = (
sortDesc.body.data as { items: Array<{ sessionOrder: number }> }
).items.map((item) => item.sessionOrder);
assert.deepEqual(ascOrders, [2, 4]);
assert.deepEqual(descOrders, [4, 2]);
const afterCount = await prisma.userAssignment.count({ where: { userId: target.id } });
assert.equal(afterCount, beforeCount);
assertNoSecrets(byStatus.raw);
assert.equal(!byStatus.raw.includes(otherId), true);
});
it('returns 401/403/404 for filter endpoint', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const unauth = await requestJson(app, `/admin/users/${randomUUID()}/assignments?status=ACTIVE`);
assert.equal(unauth.status, 401);
const registered = await register(`filter-forbidden-${Date.now()}@example.com`);
const token = registered.body.data?.sessionToken as string;
const id = (registered.body.data?.user as { id: string }).id;
const forbidden = await requestJson(app, `/admin/users/${id}/assignments?status=ACTIVE`, {
headers: authHeader(token),
});
assert.equal(forbidden.status, 403);
const { token: adminToken } = await promoteAndLogin(
`filter-404-${Date.now()}@example.com`,
);
const missing = await requestJson(
app,
`/admin/users/${randomUUID()}/assignments?status=ACTIVE`,
{ headers: authHeader(adminToken) },
);
assert.equal(missing.status, 404);
});
});
+510
View File
@@ -0,0 +1,510 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import {
AssignmentDayStatus,
AssignmentType,
UserAssignmentStatus,
UserRole,
} from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js';
import { PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js';
import {
assertNoSecrets,
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
const createdDefinitionIds: 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 ensureCourseSession(order: number, title: string) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) {
return prisma.courseSession.update({
where: { order },
data: { title },
});
}
return prisma.courseSession.create({
data: {
sessionKey: `admin-assign-test-${order}-${Date.now()}`,
order,
title,
description: 'admin assignment test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createDefinition(input: {
key: string;
sessionOrder: number;
title: string;
assignmentType?: AssignmentType;
expectedDurationDays?: number | null;
}) {
await ensureCourseSession(input.sessionOrder, `جلسه تست ${input.sessionOrder}`);
const def = await prisma.assignmentDefinition.create({
data: {
key: input.key,
sessionOrder: input.sessionOrder,
title: input.title,
description: 'desc',
instructions: 'instr',
assignmentType: input.assignmentType ?? AssignmentType.DAILY,
expectedDurationDays:
input.expectedDurationDays !== undefined ? input.expectedDurationDays : 4,
displayOrder: input.sessionOrder,
},
});
createdDefinitionIds.push(def.id);
return def;
}
describe('admin assignments', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
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()}/assignments`,
);
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(`assign-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}/assignments`, {
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(`assign-invalid-${Date.now()}@example.com`);
const { status, body } = await requestJson(app, '/admin/users/not-a-uuid/assignments', {
headers: authHeader(token),
});
assert.equal(status, 422);
assert.equal(body.error?.code, 'VALIDATION_ERROR');
});
it('returns 404 for missing user', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`assign-missing-${Date.now()}@example.com`);
const { status, body } = await requestJson(
app,
`/admin/users/${randomUUID()}/assignments`,
{ headers: authHeader(token) },
);
assert.equal(status, 404);
assert.equal(body.error?.code, 'NOT_FOUND');
});
it('returns empty items for user without assignments and records audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, userId: adminId } = await promoteAndLogin(
`assign-empty-admin-${Date.now()}@example.com`,
);
const registered = await register(`assign-empty-target-${Date.now()}@example.com`);
const externalId = (registered.body.data?.user as { id: string }).id;
const beforeCount = await prisma.userAssignment.count({
where: { user: { externalId } },
});
const { status, body, raw } = await requestJson(
app,
`/admin/users/${externalId}/assignments`,
{ headers: authHeader(token) },
);
assert.equal(status, 200);
assert.equal(body.success, true);
const data = body.data as {
user: { id: string };
items: unknown[];
summary: { total: number; completed: number; pending: number; progressPercent: number | null };
learningProgress: {
currentStep: string | null;
trainingCompleted: boolean;
courseCompleted: boolean;
totalLessons: number;
completedLessons: number;
progressPercent: number | null;
};
};
assert.equal(data.user.id, externalId);
assert.equal(data.items.length, 0);
assert.equal(data.summary.total, 0);
assert.equal(data.summary.progressPercent, null);
assert.equal(data.learningProgress.totalLessons, PROGRAM_SESSION_COUNT);
assert.equal(data.learningProgress.completedLessons, 0);
assert.equal(data.learningProgress.progressPercent, 0);
assert.equal(data.learningProgress.trainingCompleted, false);
assert.equal(data.learningProgress.courseCompleted, false);
// Journey may be absent until first access; null is valid.
assert.ok(
data.learningProgress.currentStep === null ||
typeof data.learningProgress.currentStep === 'string',
);
assertNoSecrets(raw);
assert.equal(!/draftData|completionNote|reportText|passwordHash/i.test(raw), true);
const afterCount = await prisma.userAssignment.count({
where: { user: { externalId } },
});
assert.equal(afterCount, beforeCount);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_VIEW_ASSIGNMENTS,
targetId: externalId,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = audit!.metadata as Record<string, unknown> | null;
assert.equal(meta?.view, 'assignments');
assert.equal(meta?.externalId, externalId);
assert.equal(!JSON.stringify(meta).includes('password'), true);
});
it('lists only the target user assignments with correct fields and summary', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`assign-list-admin-${stamp}@example.com`);
const targetReg = await register(`assign-list-target-${stamp}@example.com`);
const otherReg = await register(`assign-list-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const otherUser = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
const defA = await createDefinition({
key: `admin-a-${stamp}`,
sessionOrder: 3,
title: 'تمرین شادی',
expectedDurationDays: 4,
});
const defB = await createDefinition({
key: `admin-b-${stamp}`,
sessionOrder: 5,
title: 'تمرین معنا',
assignmentType: AssignmentType.ONE_TIME,
expectedDurationDays: null,
});
const defOther = await createDefinition({
key: `admin-other-${stamp}`,
sessionOrder: 4,
title: 'تکلیف دیگری',
});
const activatedAt = new Date('2026-01-10T10:00:00.000Z');
const startedAt = new Date('2026-01-11T10:00:00.000Z');
const completedAt = new Date('2026-01-12T10:00:00.000Z');
const uaCompleted = await prisma.userAssignment.create({
data: {
userId: targetUser.id,
definitionId: defA.id,
status: UserAssignmentStatus.COMPLETED,
activatedAt,
startedAt,
completedAt,
},
});
await prisma.assignmentDailyEntry.createMany({
data: [
{
userAssignmentId: uaCompleted.id,
userId: targetUser.id,
entryDate: new Date('2026-01-11'),
dayStatus: AssignmentDayStatus.done,
responseData: {},
},
{
userAssignmentId: uaCompleted.id,
userId: targetUser.id,
entryDate: new Date('2026-01-12'),
dayStatus: AssignmentDayStatus.done,
responseData: {},
},
{
userAssignmentId: uaCompleted.id,
userId: targetUser.id,
entryDate: new Date('2026-01-13'),
dayStatus: AssignmentDayStatus.skipped,
responseData: {},
},
],
});
await prisma.userAssignment.create({
data: {
userId: targetUser.id,
definitionId: defB.id,
status: UserAssignmentStatus.ACTIVE,
activatedAt: new Date('2026-01-15T10:00:00.000Z'),
},
});
await prisma.userAssignment.create({
data: {
userId: otherUser.id,
definitionId: defOther.id,
status: UserAssignmentStatus.IN_PROGRESS,
},
});
await ensureCourseSession(1, 'جلسه تست 1');
await prisma.sessionProgress.create({
data: {
userId: targetUser.id,
sessionOrder: 1,
status: 'completed',
completedAt: new Date(),
},
});
await prisma.learningJourney.upsert({
where: { userId: targetUser.id },
create: {
userId: targetUser.id,
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
},
update: {
preTestCompletedAt: new Date(),
trainingStartedAt: new Date(),
trainingCompletedAt: null,
postTestCompletedAt: null,
},
});
const { status, body, raw } = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
assert.equal(status, 200);
assertNoSecrets(raw);
assert.equal(!/draftData|completionNote|reportText|passwordHash|tokenHash/i.test(raw), true);
const data = body.data as {
items: Array<{
assignmentId: string;
definitionTitle: string;
sessionOrder: number;
sessionTitle: string | null;
status: string;
assignedAt: string;
startedAt: string | null;
completedAt: string | null;
doneDayCount: number;
expectedDurationDays: number | null;
progressPercent: number | null;
programId: null;
}>;
summary: {
total: number;
completed: number;
inProgress: number;
pending: number;
notStarted: number;
progressPercent: number | null;
};
learningProgress: {
currentStep: string | null;
trainingStarted: boolean;
trainingCompleted: boolean;
courseCompleted: boolean;
completedLessons: number;
totalLessons: number;
progressPercent: number | null;
};
};
assert.equal(data.items.length, 2);
assert.equal(
data.items.every((item) => item.assignmentId !== undefined),
true,
);
assert.equal(data.items[0]!.sessionOrder, 3);
assert.equal(data.items[0]!.definitionTitle, 'تمرین شادی');
assert.equal(data.items[0]!.status, UserAssignmentStatus.COMPLETED);
assert.equal(data.items[0]!.assignedAt, activatedAt.toISOString());
assert.equal(data.items[0]!.startedAt, startedAt.toISOString());
assert.equal(data.items[0]!.completedAt, completedAt.toISOString());
assert.equal(data.items[0]!.doneDayCount, 2);
assert.equal(data.items[0]!.expectedDurationDays, 4);
assert.equal(data.items[0]!.progressPercent, 50);
assert.equal(data.items[0]!.programId, null);
assert.ok(data.items[0]!.sessionTitle);
assert.equal(data.items[1]!.sessionOrder, 5);
assert.equal(data.items[1]!.status, UserAssignmentStatus.ACTIVE);
assert.equal(data.items[1]!.progressPercent, null);
assert.equal(data.items[1]!.expectedDurationDays, null);
assert.equal(data.summary.total, 2);
assert.equal(data.summary.completed, 1);
assert.equal(data.summary.inProgress, 1);
assert.equal(data.summary.pending, 0);
assert.equal(data.summary.notStarted, 0);
assert.equal(data.summary.progressPercent, 50);
assert.equal(data.learningProgress.trainingStarted, true);
assert.equal(data.learningProgress.trainingCompleted, false);
assert.equal(data.learningProgress.courseCompleted, false);
assert.equal(data.learningProgress.completedLessons, 1);
assert.equal(data.learningProgress.totalLessons, PROGRAM_SESSION_COUNT);
assert.equal(
data.learningProgress.progressPercent,
Math.round((1 / PROGRAM_SESSION_COUNT) * 100),
);
assert.equal(data.learningProgress.currentStep, 'training_in_progress');
});
it('does not mutate assignment or journey rows on GET', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`assign-ro-admin-${stamp}@example.com`);
const registered = await register(`assign-ro-target-${stamp}@example.com`);
const externalId = (registered.body.data?.user as { id: string }).id;
const user = await prisma.user.findUniqueOrThrow({ where: { externalId } });
const def = await createDefinition({
key: `admin-ro-${stamp}`,
sessionOrder: 2,
title: 'فقط مشاهده',
});
const ua = await prisma.userAssignment.create({
data: {
userId: user.id,
definitionId: def.id,
status: UserAssignmentStatus.IN_PROGRESS,
startedAt: new Date('2026-02-01T08:00:00.000Z'),
},
});
const beforeUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
const beforeJourney = await prisma.learningJourney.upsert({
where: { userId: user.id },
create: { userId: user.id },
update: {},
});
const { status } = await requestJson(app, `/admin/users/${externalId}/assignments`, {
headers: authHeader(token),
});
assert.equal(status, 200);
const afterUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } });
const afterJourney = await prisma.learningJourney.findUniqueOrThrow({
where: { userId: user.id },
});
assert.equal(afterUa.status, beforeUa.status);
assert.equal(afterUa.updatedAt.toISOString(), beforeUa.updatedAt.toISOString());
assert.equal(afterUa.completedAt, beforeUa.completedAt);
assert.equal(
afterJourney.trainingCompletedAt?.toISOString() ?? null,
beforeJourney.trainingCompletedAt?.toISOString() ?? null,
);
assert.equal(
afterJourney.postTestCompletedAt?.toISOString() ?? null,
beforeJourney.postTestCompletedAt?.toISOString() ?? null,
);
});
});
+348
View File
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import { ChatRole, 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.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 ensureCourseSession(order: number) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) return existing;
return prisma.courseSession.create({
data: {
sessionKey: `admin-chat-test-${order}`,
order,
title: `Test Session ${order}`,
description: 'test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createChatWithMessages(
userId: bigint,
sessionOrder: number,
messages: Array<{ role: ChatRole; content: string }>,
) {
await ensureCourseSession(sessionOrder);
const session = await prisma.chatSession.create({
data: {
userId,
sessionOrder,
messages: {
create: messages.map((message) => ({
role: message.role,
content: message.content,
})),
},
},
});
return session;
}
describe('admin chat sessions', () => {
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 for chat list', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { status, raw } = await requestJson(
app,
`/admin/users/${randomUUID()}/chat-sessions`,
);
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(`chat-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}/chat-sessions`, {
headers: authHeader(token),
});
assert.equal(status, 403);
assert.equal(body.error?.code, 'FORBIDDEN');
assertNoSecrets(raw);
});
it('returns 422 for invalid externalId and sessionId', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`chat-invalid-${Date.now()}@example.com`);
const badUser = await requestJson(app, '/admin/users/not-a-uuid/chat-sessions', {
headers: authHeader(token),
});
assert.equal(badUser.status, 422);
assert.equal(badUser.body.error?.code, 'VALIDATION_ERROR');
const badSession = await requestJson(
app,
`/admin/users/${randomUUID()}/chat-sessions/not-numeric`,
{ headers: authHeader(token) },
);
assert.equal(badSession.status, 422);
assert.equal(badSession.body.error?.code, 'VALIDATION_ERROR');
assertNoSecrets(badUser.raw);
assertNoSecrets(badSession.raw);
});
it('returns 404 for missing user and empty list for user without chats', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`chat-empty-admin-${stamp}@example.com`);
const missing = await requestJson(app, `/admin/users/${randomUUID()}/chat-sessions`, {
headers: authHeader(token),
});
assert.equal(missing.status, 404);
const target = await register(`chat-empty-${stamp}@example.com`);
const targetId = (target.body.data?.user as { id: string }).id;
const empty = await requestJson(app, `/admin/users/${targetId}/chat-sessions`, {
headers: authHeader(token),
});
assert.equal(empty.status, 200);
const data = empty.body.data as {
items: unknown[];
pagination: { total: number };
};
assert.equal(data.items.length, 0);
assert.equal(data.pagination.total, 0);
assertNoSecrets(empty.raw);
});
it('lists only the target user sessions with pagination, counts, and audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const targetReg = await register(`chat-list-${stamp}@example.com`);
const otherReg = await register(`chat-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
await createChatWithMessages(target.id, 1, [
{ role: ChatRole.user, content: 'hello one' },
{ role: ChatRole.assistant, content: 'hi one' },
]);
await createChatWithMessages(target.id, 2, [
{ role: ChatRole.user, content: 'hello two' },
]);
await createChatWithMessages(other.id, 1, [
{ role: ChatRole.user, content: 'secret other user' },
]);
const { token, userId: adminId } = await promoteAndLogin(
`chat-list-admin-${stamp}@example.com`,
);
const listed = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions?page=1&pageSize=1&sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
assert.equal(listed.status, 200);
assertNoSecrets(listed.raw);
const page1 = listed.body.data as {
items: Array<{ id: string; messageCount: number; sessionOrder: number; title: string }>;
pagination: { page: number; pageSize: number; total: number; totalPages: number };
};
assert.equal(page1.pagination.page, 1);
assert.equal(page1.pagination.pageSize, 1);
assert.equal(page1.pagination.total, 2);
assert.equal(page1.pagination.totalPages, 2);
assert.equal(page1.items.length, 1);
assert.equal(page1.items[0]?.sessionOrder, 1);
assert.equal(page1.items[0]?.messageCount, 2);
assert.match(page1.items[0]?.title ?? '', /جلسه 1/);
const page2 = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions?page=2&pageSize=1&sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
const page2Data = page2.body.data as {
items: Array<{ sessionOrder: number; messageCount: number }>;
};
assert.equal(page2Data.items[0]?.sessionOrder, 2);
assert.equal(page2Data.items[0]?.messageCount, 1);
const raw = listed.raw;
assert.equal(/secret other user/i.test(raw), false);
assert.equal(/passwordHash|tokenHash/i.test(raw), false);
const badSort = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions?sortBy=passwordHash`,
{ headers: authHeader(token) },
);
assert.equal(badSort.status, 422);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
targetUserId: target.id,
action: AUDIT_ACTIONS.ADMIN_VIEW_CHATS,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = JSON.stringify(audit.metadata ?? {});
assert.match(meta, /chat_sessions/);
assert.equal(/hello one|secret other/i.test(meta), false);
});
it('returns session detail with ordered messages and ownership checks', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const targetReg = await register(`chat-detail-${stamp}@example.com`);
const otherReg = await register(`chat-detail-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
const owned = await createChatWithMessages(target.id, 3, [
{ role: ChatRole.user, content: 'first user msg' },
{ role: ChatRole.assistant, content: 'first assistant msg' },
{ role: ChatRole.user, content: 'second user msg' },
]);
const foreign = await createChatWithMessages(other.id, 3, [
{ role: ChatRole.user, content: 'foreign message' },
]);
const { token, userId: adminId } = await promoteAndLogin(
`chat-detail-admin-${stamp}@example.com`,
);
const detail = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions/${owned.id.toString()}`,
{ headers: authHeader(token) },
);
assert.equal(detail.status, 200);
assertNoSecrets(detail.raw);
const data = detail.body.data as {
session: { id: string; messageCount: number };
messages: Array<{ role: string; content: string; sequence: number }>;
};
assert.equal(data.session.id, owned.id.toString());
assert.equal(data.session.messageCount, 3);
assert.equal(data.messages.length, 3);
assert.equal(data.messages[0]?.content, 'first user msg');
assert.equal(data.messages[0]?.role, 'user');
assert.equal(data.messages[0]?.sequence, 1);
assert.equal(data.messages[1]?.role, 'assistant');
assert.equal(data.messages[2]?.sequence, 3);
assert.equal(/foreign message/i.test(detail.raw), false);
assert.equal('metadata' in (data.messages[0] ?? {}), false);
const cross = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions/${foreign.id.toString()}`,
{ headers: authHeader(token) },
);
assert.equal(cross.status, 404);
const missing = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions/999999999`,
{ headers: authHeader(token) },
);
assert.equal(missing.status, 404);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
targetUserId: target.id,
action: AUDIT_ACTIONS.ADMIN_VIEW_CHAT_MESSAGES,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = JSON.stringify(audit.metadata ?? {});
assert.match(meta, /chat_session/);
assert.equal(/first user msg|foreign message/i.test(meta), false);
});
});
+346
View File
@@ -0,0 +1,346 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import {
AssignmentType,
ChatRole,
UserAssignmentStatus,
UserRole,
} from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import { sanitizeAuditMetadata } from '../src/modules/admin/audit.service.js';
import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js';
import {
assertNoSecrets,
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
/**
* Stage 11 — cross-cutting admin security / integrity checks.
* Prefer ownership-in-query, explicit DTOs, and fail-closed 404 for foreign nested ids.
*/
const app = createTestApp();
const createdUserIds: bigint[] = [];
const createdDefinitionIds: bigint[] = [];
const SENSITIVE_RE =
/passwordHash|tokenHash|\$argon2|draftData|reportText|completionNote|responseData|must-not-leak|secret report|foreign-only/i;
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 ensureCourseSession(order: number) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) return existing;
return prisma.courseSession.create({
data: {
sessionKey: `sec-audit-${order}-${Date.now()}`,
order,
title: `Security Audit Session ${order}`,
description: 'security',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
describe('admin security audit (stage 11)', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdDefinitionIds.length > 0) {
await prisma.userAssignment.deleteMany({
where: { definitionId: { in: createdDefinitionIds } },
});
}
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
it('sanitizeAuditMetadata blocks assignment private payload keys', () => {
const cleaned = sanitizeAuditMetadata({
view: 'assignment_detail',
draftData: { goal: 'hidden' },
reportText: 'hidden report',
completionNote: 'hidden note',
responseData: { x: 1 },
rawValue: 4,
systemMessage: 'sys',
prompt: 'ai prompt',
externalId: '11111111-1111-1111-1111-111111111111',
});
const raw = JSON.stringify(cleaned);
assert.equal(/hidden|sys|ai prompt/i.test(raw), false);
assert.equal(/draftData|reportText|completionNote|responseData|rawValue|systemMessage|prompt/i.test(raw), false);
assert.match(raw, /assignment_detail/);
assert.match(raw, /11111111-1111-1111-1111-111111111111/);
});
it('denies unauthenticated and non-admin access across admin surface', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const admin = await promoteAndLogin(`sec-admin-${stamp}@example.com`);
const normal = await register(`sec-user-${stamp}@example.com`);
assert.equal(normal.status, 201);
const normalToken = normal.body.data?.sessionToken as string;
const targetId = (normal.body.data?.user as { id: string }).id;
const paths = [
'/admin/ping',
'/admin/stats',
'/admin/users',
`/admin/users/${targetId}`,
`/admin/users/${targetId}/overview`,
`/admin/users/${targetId}/assessments`,
`/admin/users/${targetId}/chat-sessions`,
`/admin/users/${targetId}/assignments`,
`/admin/users/${targetId}/assignments/export`,
];
for (const path of paths) {
const unauth = await requestJson(app, path);
assert.equal(unauth.status, 401, `${path} should be 401 without auth`);
assert.equal(unauth.body.error?.code, 'UNAUTHORIZED');
assertNoSecrets(unauth.raw);
const forbidden = await requestJson(app, path, {
headers: authHeader(normalToken),
});
assert.equal(forbidden.status, 403, `${path} should be 403 for non-admin`);
assert.equal(forbidden.body.error?.code, 'FORBIDDEN');
assertNoSecrets(forbidden.raw);
}
// Admin still allowed (smoke) — proves middleware order does not break happy path.
const ok = await requestJson(app, `/admin/users/${targetId}/overview`, {
headers: authHeader(admin.token),
});
assert.equal(ok.status, 200);
});
it('enforces nested ownership (assignment + chat) and safe empty overview', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const admin = await promoteAndLogin(`sec-idor-admin-${stamp}@example.com`);
const targetReg = await register(`sec-idor-target-${stamp}@example.com`);
const otherReg = await register(`sec-idor-other-${stamp}@example.com`);
assert.equal(targetReg.status, 201);
assert.equal(otherReg.status, 201);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const otherUser = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
await ensureCourseSession(1);
const def = await prisma.assignmentDefinition.create({
data: {
key: `sec-idor-def-${stamp}`,
sessionOrder: 1,
title: 'Owned Assignment',
description: 'd',
instructions: 'i',
assignmentType: AssignmentType.ONE_TIME,
expectedDurationDays: 1,
displayOrder: 1,
},
});
createdDefinitionIds.push(def.id);
const owned = await prisma.userAssignment.create({
data: {
userId: targetUser.id,
definitionId: def.id,
status: UserAssignmentStatus.ACTIVE,
activatedAt: new Date(),
draftData: { secretField: 'must-not-leak' },
completionNote: 'private note',
},
});
const foreign = await prisma.userAssignment.create({
data: {
userId: otherUser.id,
definitionId: def.id,
status: UserAssignmentStatus.COMPLETED,
activatedAt: new Date(),
completedAt: new Date(),
draftData: { secretField: 'foreign-only' },
completionNote: 'foreign note',
},
});
const foreignChat = await prisma.chatSession.create({
data: {
userId: otherUser.id,
sessionOrder: 2,
startedAt: new Date(),
messages: {
create: [{ role: ChatRole.user, content: 'foreign-only chat body' }],
},
},
});
// Empty / incomplete user: overview must not throw.
const emptyReg = await register(`sec-empty-${stamp}@example.com`);
const emptyId = (emptyReg.body.data?.user as { id: string }).id;
const emptyOverview = await requestJson(app, `/admin/users/${emptyId}/overview`, {
headers: authHeader(admin.token),
});
assert.equal(emptyOverview.status, 200);
assert.equal((emptyOverview.body.data?.assignments as { total: number }).total, 0);
assert.equal((emptyOverview.body.data?.recentActivities as unknown[]).length, 0);
assert.equal(SENSITIVE_RE.test(emptyOverview.raw), false);
// Owned assignment detail OK; foreign nested id → 404 (no existence leak).
const ownedDetail = await requestJson(
app,
`/admin/users/${targetId}/assignments/${owned.id.toString()}`,
{ headers: authHeader(admin.token) },
);
assert.equal(ownedDetail.status, 200);
assert.equal(SENSITIVE_RE.test(ownedDetail.raw), false);
assert.equal('draftData' in (ownedDetail.body.data?.assignment as object), false);
const foreignAssign = await requestJson(
app,
`/admin/users/${targetId}/assignments/${foreign.id.toString()}`,
{ headers: authHeader(admin.token) },
);
assert.equal(foreignAssign.status, 404);
assert.equal(foreignAssign.body.error?.code, 'NOT_FOUND');
assert.equal(SENSITIVE_RE.test(foreignAssign.raw), false);
const foreignSession = await requestJson(
app,
`/admin/users/${targetId}/chat-sessions/${foreignChat.id.toString()}`,
{ headers: authHeader(admin.token) },
);
assert.equal(foreignSession.status, 404);
assert.equal(foreignSession.body.error?.code, 'NOT_FOUND');
assert.equal(/foreign-only chat/i.test(foreignSession.raw), false);
// Search/filter must stay inside target userId scope.
const filtered = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=Owned&status=ACTIVE`,
{ headers: authHeader(admin.token) },
);
assert.equal(filtered.status, 200);
const items = filtered.body.data?.items as Array<{ assignmentId: string }>;
assert.equal(items.length, 1);
assert.equal(items[0]?.assignmentId, owned.id.toString());
assert.equal(
items.some((item) => item.assignmentId === foreign.id.toString()),
false,
);
assert.equal(SENSITIVE_RE.test(filtered.raw), false);
// Invalid whitelist sort rejected.
const badSort = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=passwordHash`,
{ headers: authHeader(admin.token) },
);
assert.equal(badSort.status, 422);
assert.equal(badSort.body.error?.code, 'VALIDATION_ERROR');
// Route order: /overview and /export resolve as dedicated handlers (not :externalId / :assignmentId).
const overview = await requestJson(app, `/admin/users/${targetId}/overview`, {
headers: authHeader(admin.token),
});
assert.equal(overview.status, 200);
assert.ok(overview.body.data?.assignments);
assert.ok(overview.body.data?.learningProgress);
assert.equal(SENSITIVE_RE.test(overview.raw), false);
assert.equal(/must-not-leak|foreign-only/i.test(overview.raw), false);
const exportRes = await requestJson(app, `/admin/users/${targetId}/assignments/export`, {
headers: authHeader(admin.token),
});
// Dedicated /export route must win (200 workbook). If it were captured as :assignmentId,
// digit-only validation would yield 422 instead.
assert.equal(exportRes.status, 200);
assert.notEqual(exportRes.body.error?.code, 'VALIDATION_ERROR');
assert.equal(SENSITIVE_RE.test(exportRes.raw), false);
const missingUser = await requestJson(app, `/admin/users/${randomUUID()}/overview`, {
headers: authHeader(admin.token),
});
assert.equal(missingUser.status, 404);
// Audit for overview stays minimal (no private payloads).
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: admin.userId,
action: AUDIT_ACTIONS.ADMIN_VIEW_USER_OVERVIEW,
targetId: targetId,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const metaRaw = JSON.stringify(audit.metadata ?? null);
assert.equal(/draftData|reportText|must-not-leak|passwordHash/i.test(metaRaw), false);
assert.match(metaRaw, /user_overview/);
});
});
+338
View File
@@ -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/);
});
});
+260
View File
@@ -0,0 +1,260 @@
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);
});
});
+329
View File
@@ -0,0 +1,329 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import {
AssignmentType,
AttemptPhase,
AttemptStatus,
SessionProgressStatus,
UserAssignmentStatus,
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[] = [];
const createdDefinitionIds: 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 ensureCourseSession(order: number) {
const existing = await prisma.courseSession.findUnique({ where: { order } });
if (existing) return existing;
return prisma.courseSession.create({
data: {
sessionKey: `overview-test-${order}-${Date.now()}`,
order,
title: `Overview Session ${order}`,
description: 'overview',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
describe('admin user overview', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdUserIds.length > 0) {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ actorUserId: { in: createdUserIds } },
{ targetUserId: { in: createdUserIds } },
],
},
});
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
if (createdDefinitionIds.length > 0) {
await prisma.assignmentDefinition.deleteMany({
where: { id: { in: createdDefinitionIds } },
});
}
});
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()}/overview`);
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(`overview-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}/overview`, {
headers: authHeader(token),
});
assert.equal(status, 403);
assert.equal(body.error?.code, 'FORBIDDEN');
assertNoSecrets(raw);
});
it('returns 422 for invalid externalId and 404 for missing user', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`overview-invalid-${Date.now()}@example.com`);
const bad = await requestJson(app, '/admin/users/not-a-uuid/overview', {
headers: authHeader(token),
});
assert.equal(bad.status, 422);
const missing = await requestJson(app, `/admin/users/${randomUUID()}/overview`, {
headers: authHeader(token),
});
assert.equal(missing.status, 404);
});
it('returns empty overview for user without activity and records audit', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, userId: adminId } = await promoteAndLogin(
`overview-empty-admin-${Date.now()}@example.com`,
);
const registered = await register(`overview-empty-target-${Date.now()}@example.com`);
const externalId = (registered.body.data?.user as { id: string }).id;
const { status, body, raw } = await requestJson(app, `/admin/users/${externalId}/overview`, {
headers: authHeader(token),
});
assert.equal(status, 200);
assertNoSecrets(raw);
const data = body.data as {
user: { id: string };
assignments: { total: number; reported: number; progressPercent: number | null };
learningProgress: { completedLessons: number; progressPercent: number | null };
assessments: { completedCount: number; overallStatus: string; remainingCount: number };
recentActivities: unknown[];
};
assert.equal(data.user.id, externalId);
assert.equal(data.assignments.total, 0);
assert.equal(data.assignments.reported, 0);
assert.equal(data.assignments.progressPercent, null);
assert.equal(data.learningProgress.completedLessons, 0);
assert.equal(data.assessments.completedCount, 0);
assert.equal(data.assessments.overallStatus, 'none');
assert.equal(data.assessments.remainingCount, 2);
assert.equal(data.recentActivities.length, 0);
const audit = await prisma.auditLog.findFirst({
where: {
actorUserId: adminId,
action: AUDIT_ACTIONS.ADMIN_VIEW_USER_OVERVIEW,
targetId: externalId,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(audit);
const meta = audit!.metadata as Record<string, unknown>;
assert.equal(meta.view, 'user_overview');
assert.equal(meta.externalId, externalId);
});
it('returns owned overview only and omits sensitive payloads', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`overview-data-admin-${stamp}@example.com`);
const targetReg = await register(`overview-data-target-${stamp}@example.com`);
const otherReg = await register(`overview-data-other-${stamp}@example.com`);
const targetId = (targetReg.body.data?.user as { id: string }).id;
const otherId = (otherReg.body.data?.user as { id: string }).id;
const target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } });
const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } });
await ensureCourseSession(1);
await prisma.sessionProgress.create({
data: {
userId: target.id,
sessionOrder: 1,
status: SessionProgressStatus.completed,
completedAt: new Date('2026-06-01T10:00:00.000Z'),
},
});
await prisma.sessionProgress.create({
data: {
userId: other.id,
sessionOrder: 1,
status: SessionProgressStatus.completed,
completedAt: new Date('2026-06-01T11:00:00.000Z'),
},
});
await ensureCourseSession(2);
const def = await prisma.assignmentDefinition.create({
data: {
key: `overview-def-${stamp}`,
sessionOrder: 2,
title: 'Overview Homework',
description: 'd',
instructions: 'i',
assignmentType: AssignmentType.DAILY,
expectedDurationDays: 4,
displayOrder: 2,
},
});
createdDefinitionIds.push(def.id);
await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: def.id,
status: UserAssignmentStatus.REPORTED,
activatedAt: new Date('2026-06-02T10:00:00.000Z'),
lastActivityAt: new Date('2026-06-03T10:00:00.000Z'),
draftData: { secret: 'nope' },
completionNote: 'private note',
},
});
await prisma.learningJourney.upsert({
where: { userId: target.id },
create: {
userId: target.id,
preTestCompletedAt: new Date('2026-05-20T10:00:00.000Z'),
trainingStartedAt: new Date('2026-05-21T10:00:00.000Z'),
},
update: {
preTestCompletedAt: new Date('2026-05-20T10:00:00.000Z'),
trainingStartedAt: new Date('2026-05-21T10:00:00.000Z'),
},
});
await prisma.assessmentAttempt.create({
data: {
userId: target.id,
phase: AttemptPhase.pre,
status: AttemptStatus.scored,
submittedAt: new Date('2026-05-20T10:00:00.000Z'),
scoredAt: new Date('2026-05-20T10:00:00.000Z'),
answers: {
create: [
{
questionId: 'q1',
rawValue: 3,
points: 3,
dimension: 'hope',
isReverseScored: false,
},
],
},
},
});
const beforeAssignments = await prisma.userAssignment.count({ where: { userId: target.id } });
const { status, body, raw } = await requestJson(app, `/admin/users/${targetId}/overview`, {
headers: authHeader(token),
});
assert.equal(status, 200);
assertNoSecrets(raw);
assert.equal(!/nope|private note|passwordHash|draftData|reportText/i.test(raw), true);
const data = body.data as {
assignments: {
total: number;
reported: number;
inProgress: number;
progressPercent: number | null;
};
learningProgress: { completedLessons: number; currentStep: string | null };
assessments: {
completedCount: number;
hasPretest: boolean;
hasPosttest: boolean;
overallStatus: string;
};
recentActivities: Array<{ type: string; title: string; relatedId: string | null }>;
};
assert.equal(data.assignments.total, 1);
assert.equal(data.assignments.reported, 1);
assert.equal(data.assignments.inProgress, 1);
assert.equal(data.assignments.progressPercent, 0);
assert.equal(data.learningProgress.completedLessons, 1);
assert.equal(data.learningProgress.currentStep, 'training_in_progress');
assert.equal(data.assessments.completedCount, 1);
assert.equal(data.assessments.hasPretest, true);
assert.equal(data.assessments.hasPosttest, false);
assert.equal(data.assessments.overallStatus, 'partial');
assert.ok(data.recentActivities.length > 0);
assert.equal(
data.recentActivities.every((item) => !item.title.includes('nope')),
true,
);
const afterAssignments = await prisma.userAssignment.count({ where: { userId: target.id } });
assert.equal(afterAssignments, beforeAssignments);
});
});
+240
View File
@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { AuthProvider, UserRole } from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js';
import { sanitizeAuditMetadata } from '../src/modules/admin/audit.service.js';
import {
assertNoSecrets,
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
describe('admin role and requireAdmin', () => {
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 } } });
}
});
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;
}
it('returns 401 for /admin/ping without a session', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { status, body, raw } = await requestJson(app, '/admin/ping');
assert.equal(status, 401);
assert.equal(body.error?.code, 'UNAUTHORIZED');
assertNoSecrets(raw);
});
it('returns 403 for a normal user calling /admin/ping', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const email = `user-${Date.now()}@example.com`;
const registered = await register(email);
assert.equal(registered.status, 201);
const token = registered.body.data?.sessionToken as string;
assert.ok(token);
const { status, body, raw } = await requestJson(app, '/admin/ping', {
headers: authHeader(token),
});
assert.equal(status, 403);
assert.equal(body.error?.code, 'FORBIDDEN');
assertNoSecrets(raw);
const user = await prisma.user.findFirst({
where: { identities: { some: { email } } },
});
assert.ok(user);
assert.equal(user.role, UserRole.user);
const denied = await prisma.auditLog.findFirst({
where: {
actorUserId: user.id,
action: AUDIT_ACTIONS.ADMIN_ACCESS_DENIED,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(denied);
const metaRaw = JSON.stringify(denied.metadata ?? {});
assert.equal(/passwordHash/i.test(metaRaw), false);
assert.equal(/tokenHash/i.test(metaRaw), false);
assert.equal(/sessionToken/i.test(metaRaw), false);
});
it('allows an admin user through requireAdmin', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const email = `admin-${Date.now()}@example.com`;
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);
assert.equal((login.body.data?.user as { role: string }).role, UserRole.admin);
const token = login.body.data?.sessionToken as string;
const { status, body, raw } = await requestJson(app, '/admin/ping', {
headers: authHeader(token),
});
assert.equal(status, 200);
assert.equal(body.success, true);
assert.equal((body.data as { ok?: boolean; role?: string })?.ok, true);
assert.equal((body.data as { role?: string })?.role, UserRole.admin);
assertNoSecrets(raw);
const loginAudit = await prisma.auditLog.findFirst({
where: {
actorUserId: user.id,
action: AUDIT_ACTIONS.ADMIN_LOGIN,
},
orderBy: { createdAt: 'desc' },
});
assert.ok(loginAudit);
});
it('promote script logic is idempotent and does not create missing users', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const missing = await prisma.authIdentity.findFirst({
where: {
provider: AuthProvider.password,
email: `missing-${Date.now()}@example.com`,
},
});
assert.equal(missing, null);
const email = `promote-${Date.now()}@example.com`;
const registered = await register(email);
const externalId = (registered.body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
assert.equal(user.role, UserRole.user);
const profileBefore = await prisma.userProfile.findUnique({ where: { userId: user.id } });
const identityBefore = await prisma.authIdentity.findFirst({
where: { userId: user.id, provider: AuthProvider.password },
});
assert.ok(identityBefore?.passwordHash);
const sessionsBefore = await prisma.authSession.count({ where: { userId: user.id } });
await prisma.user.update({
where: { id: user.id },
data: { role: UserRole.admin },
});
await prisma.user.update({
where: { id: user.id },
data: { role: UserRole.admin },
});
const after = await prisma.user.findUnique({
where: { id: user.id },
include: { identities: true, profile: true },
});
assert.ok(after);
assert.equal(after.role, UserRole.admin);
assert.equal(after.profile?.name ?? null, profileBefore?.name ?? null);
assert.equal(
after.identities.find((i) => i.provider === AuthProvider.password)?.passwordHash,
identityBefore.passwordHash,
);
const sessionsAfter = await prisma.authSession.count({ where: { userId: user.id } });
assert.equal(sessionsAfter, sessionsBefore);
});
it('sanitizeAuditMetadata drops secrets and chat-like payloads', () => {
const cleaned = sanitizeAuditMetadata({
reason: 'insufficient_role',
passwordHash: '$argon2id$v=19$m=19456,t=2,p=1$secret',
tokenHash: 'abc123',
sessionToken: 'raw-token-value',
content: 'full chat message should not be stored',
answers: [{ questionId: 'pcq_01', rawValue: 5 }],
messages: [{ role: 'user', content: 'hello' }],
draftData: { secretField: 'must-not-persist' },
reportText: 'private homework report',
completionNote: 'private note',
responseData: { nested: true },
rawValue: 5,
systemMessage: 'hidden system prompt',
prompt: 'provider prompt',
email: 'admin@example.com',
alreadyAdmin: true,
});
const raw = JSON.stringify(cleaned);
assert.equal(/passwordHash/i.test(raw), false);
assert.equal(/tokenHash/i.test(raw), false);
assert.equal(/sessionToken/i.test(raw), false);
assert.equal(/full chat message/i.test(raw), false);
assert.equal(/pcq_01/i.test(raw), false);
assert.equal(/must-not-persist|private homework|private note|hidden system|provider prompt/i.test(raw), false);
assert.equal(/draftData|reportText|completionNote|responseData|rawValue|systemMessage/i.test(raw), false);
assert.match(raw, /insufficient_role/);
assert.match(raw, /admin@example.com/);
});
});
+578
View File
@@ -0,0 +1,578 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { ChatRole } from '@prisma/client';
import { prisma } from '../src/lib/prisma.js';
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
import { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js';
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js';
import { getSessionByOrder } from '../src/modules/sessions/catalog.js';
import { seedCourseSessions } from '../src/modules/sessions/seedCatalog.js';
import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.service.js';
import {
assertNoSecrets,
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
function pcqAnswers(value: number) {
return PCQ_QUESTION_IDS.map((questionId) => ({
questionId,
selectedValue: String(value),
}));
}
async function registerUser(suffix: string) {
const email = `assignments-${suffix}-${Date.now()}@example.com`;
const { status, body } = await requestJson(app, '/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password: 'password12' }),
});
assert.equal(status, 201);
const token = body.data?.sessionToken as string;
const externalId = (body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
createdUserIds.push(user.id);
return { token, user };
}
async function completePreTest(token: string) {
const res = await requestJson(app, '/assessments/attempts', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
});
assert.equal(res.status, 201);
}
async function openChatAndComplete(token: string, order: number) {
const chat = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: order }),
});
assert.equal(chat.status, 201, `chat create ${order}: ${JSON.stringify(chat.body)}`);
const chatId = (chat.body.data as { id: string }).id;
await prisma.chatMessage.createMany({
data: [
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` },
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` },
],
});
const session = getSessionByOrder(order)!;
const complete = await requestJson(app, `/sessions/${order}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(session)),
chatSessionId: chatId,
}),
});
assert.equal(complete.status, 200, `complete ${order}: ${JSON.stringify(complete.body)}`);
return { chatId };
}
describe('assignments API', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
if (dbAvailable) {
await seedCourseSessions();
await seedAssignmentDefinitions();
}
});
after(async () => {
if (dbAvailable && createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
});
it('activates cumulative assignments, reports, drafts, daily entries, and isolation', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const owner = await registerUser('owner');
const other = await registerUser('other');
await completePreTest(owner.token);
await completePreTest(other.token);
// Session 1 has no assignment
await openChatAndComplete(owner.token, 1);
let list = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(owner.token),
});
assert.equal(list.status, 200);
assert.equal((list.body.data?.assignments as unknown[]).length, 0);
// Complete session 2 → joy assignment activates
await openChatAndComplete(owner.token, 2);
list = await requestJson(app, '/assignments/active', {
headers: authHeader(owner.token),
});
assert.equal(list.status, 200);
const activeAfter2 = list.body.data?.assignments as Array<{
id: string;
definition: { key: string; sessionOrder: number };
status: string;
}>;
assert.equal(activeAfter2.length, 1);
assert.equal(activeAfter2[0]?.definition.key, 'joy-activities');
const joyId = activeAfter2[0]!.id;
// Completing again does not duplicate
await requestJson(app, '/sessions/2/complete', {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)),
}),
});
list = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(owner.token),
});
assert.equal((list.body.data?.assignments as unknown[]).length, 1);
// Backdate session 2 completion so session 3 unlocks (24h gate for order >= 3)
await prisma.sessionProgress.update({
where: {
userId_sessionOrder: { userId: owner.user.id, sessionOrder: 2 },
},
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
// Incomplete homework must NOT block starting session 3
const chat3 = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({ sessionOrder: 3 }),
});
assert.equal(chat3.status, 201);
await openChatAndComplete(owner.token, 3);
list = await requestJson(app, '/assignments/active', {
headers: authHeader(owner.token),
});
const activeAfter3 = list.body.data?.assignments as Array<{
id: string;
definition: { key: string };
}>;
assert.equal(activeAfter3.length, 2);
assert.ok(activeAfter3.some((a) => a.definition.key === 'joy-activities'));
assert.ok(activeAfter3.some((a) => a.definition.key === 'meaning-purpose'));
const meaningId = activeAfter3.find((a) => a.definition.key === 'meaning-purpose')!.id;
// Joy still active after session 3
const joyStill = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(owner.token),
});
assert.equal(joyStill.status, 200);
assert.notEqual(joyStill.body.data?.status, 'COMPLETED');
// Multiple reports on different days
const report1 = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
reportText: 'امروز یک پیاده‌روی آرام انجام دادم و حالم بهتر شد.',
reportStatus: 'in_progress',
}),
});
assert.equal(report1.status, 200);
const report2 = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
reportText: 'روز بعد هم یک فیلم شاد دیدم و خندیدم.',
reportStatus: 'completed',
}),
});
assert.equal(report2.status, 200);
const joyDetail = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(owner.token),
});
assert.equal((joyDetail.body.data?.reports as unknown[]).length, 2);
// Daily entries + duplicate day conflict
const entry1 = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-10',
dayStatus: 'done',
responseData: { activity: 'پیاده‌روی', feeling: 'سبک' },
note: 'صبح',
}),
});
assert.equal(entry1.status, 200);
const entryDup = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-10',
dayStatus: 'done',
responseData: { activity: 'تکراری', feeling: '—' },
}),
});
assert.equal(entryDup.status, 409);
const entry2 = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-11',
dayStatus: 'done',
responseData: { activity: 'موسیقی', feeling: 'شاد' },
}),
});
assert.equal(entry2.status, 200);
const entry2Id = (entry2.body.data as { id: string }).id;
const entryPatch = await requestJson(app, `/assignments/${joyId}/entries/${entry2Id}`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
dayStatus: 'done',
responseData: { activity: 'موسیقی آرام', feeling: 'آرام' },
note: 'ویرایش',
}),
});
assert.equal(entryPatch.status, 200);
// Incomplete daily «done» entry must be rejected
const entryIncomplete = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-12',
dayStatus: 'done',
responseData: { activity: '', feeling: '' },
}),
});
assert.equal(entryIncomplete.status, 422);
// Skipped day allowed without activity payload
const entrySkipped = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-12',
dayStatus: 'skipped',
responseData: {},
note: 'خسته بودم',
}),
});
assert.equal(entrySkipped.status, 200);
// ONE_TIME draft save & continue
const draft = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: 'هدف کوچک', step: 'یک قدم', reflection: 'یادداشت' },
completeIfValid: false,
}),
});
assert.equal(draft.status, 200);
assert.equal((draft.body.data?.draftData as { goal: string }).goal, 'هدف کوچک');
assert.equal(draft.body.data?.status, 'IN_PROGRESS');
// Incomplete completeIfValid must fail
const incompleteSubmit = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: '', step: '', reflection: '' },
completeIfValid: true,
}),
});
assert.equal(incompleteSubmit.status, 422);
// Valid submit completes ONE_TIME
const completed = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: 'هدف به‌روز', step: 'قدم دوم', reflection: '' },
completeIfValid: true,
}),
});
assert.equal(completed.status, 200);
assert.equal(completed.body.data?.status, 'COMPLETED');
// Isolation: other user cannot read/write owner's assignment
const otherRead = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(other.token),
});
assert.equal(otherRead.status, 404);
const otherReport = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(other.token),
body: JSON.stringify({
reportText: 'تلاش برای دسترسی غیرمجاز به تکلیف دیگری',
reportStatus: 'completed',
}),
});
assert.equal(otherReport.status, 404);
const otherList = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(other.token),
});
assert.equal((otherList.body.data?.assignments as unknown[]).length, 0);
// Pending report filter — requiresReport is false on seeds → empty
const pending = await requestJson(app, '/assignments/pending-report', {
headers: authHeader(owner.token),
});
assert.equal(pending.status, 200);
assert.equal((pending.body.data?.assignments as unknown[]).length, 0);
// Session assignments endpoint
const sessionAssignments = await requestJson(app, '/sessions/2/assignments', {
headers: authHeader(owner.token),
});
assert.equal(sessionAssignments.status, 200);
assert.ok(Array.isArray(sessionAssignments.body.data?.assignments));
assertNoSecrets(JSON.stringify(list.body));
});
it('keeps ongoing assignments usable across later sessions (through day 9 / session 10)', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, user } = await registerUser('long');
await completePreTest(token);
await openChatAndComplete(token, 1);
await openChatAndComplete(token, 2);
// Unlock sessions 3–10 by backdating previous completions as we go
for (let order = 3; order <= 10; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
await openChatAndComplete(token, order);
}
const list = await requestJson(app, '/assignments/active', {
headers: authHeader(token),
});
assert.equal(list.status, 200);
const assignments = list.body.data?.assignments as Array<{
id: string;
definition: { key: string; sessionOrder: number };
}>;
// Sessions 2–10 each activate one definition → 9 active (none completed)
assert.equal(assignments.length, 9);
assert.ok(assignments.some((a) => a.definition.key === 'joy-activities'));
assert.ok(assignments.some((a) => a.definition.key === 'kindness-acts'));
const joy = assignments.find((a) => a.definition.key === 'joy-activities')!;
const entry = await requestJson(app, `/assignments/${joy.id}/entries`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
entryDate: '2026-09-13',
dayStatus: 'done',
responseData: { activity: 'فعالیت روز نهم', feeling: 'خوب' },
}),
});
assert.equal(entry.status, 200);
const finalHw = assignments.find((a) => a.definition.key === 'kindness-acts')!;
// Early complete without minValidDays / enough days must fail
const earlyComplete = await requestJson(app, `/assignments/${finalHw.id}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({}),
});
assert.equal(earlyComplete.status, 422);
const kindEntry = await requestJson(app, `/assignments/${finalHw.id}/entries`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
entryDate: '2026-09-14',
dayStatus: 'done',
responseData: {
acts: [
{
forWhom: 'همسایه',
description: 'کمک در حمل وسایل',
selfFeeling: 'سبک',
},
],
},
}),
});
assert.equal(kindEntry.status, 200);
// Still cannot complete — minValidDays is null (research undecided)
const stillBlocked = await requestJson(app, `/assignments/${finalHw.id}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({}),
});
assert.equal(stillBlocked.status, 422);
assert.notEqual(
(
await requestJson(app, `/assignments/${finalHw.id}`, {
headers: authHeader(token),
})
).body.data?.status,
'COMPLETED',
);
});
it('unlocks assignment immediately on session complete — independent of next-session cooldown', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, user } = await registerUser('unlock-gate');
await completePreTest(token);
await openChatAndComplete(token, 1);
let catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
assert.equal(catalog.status, 200);
let items = catalog.body.data?.catalog as Array<{
unlocked: boolean;
lockReason: string | null;
definition: { key: string; sessionOrder: number; displayOrder: number };
userAssignment: { id: string } | null;
}>;
const first = items.find((i) => i.definition.displayOrder === 1)!;
assert.equal(first.definition.sessionOrder, 2);
assert.equal(first.unlocked, false);
assert.match(String(first.lockReason), /جلسه 2/);
await openChatAndComplete(token, 2);
// completeSession helper only asserts 200 — fetch progress for cooldown fields
const progress = await requestJson(app, '/sessions/progress', {
headers: authHeader(token),
});
assert.equal(progress.status, 200);
assert.ok(progress.body.data?.waitingForSessionOrder === 3);
assert.ok(progress.body.data?.nextSessionUnlocksAt);
// Re-complete to read unlockedAssignments payload from complete endpoint
const redo = await requestJson(app, '/sessions/2/complete', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)),
}),
});
assert.equal(redo.status, 200);
const unlockedFromComplete = redo.body.data?.unlockedAssignments as Array<{
unlocked: boolean;
definition: { key: string; sessionOrder: number };
}>;
assert.ok(Array.isArray(unlockedFromComplete));
assert.equal(unlockedFromComplete.length, 1);
assert.equal(unlockedFromComplete[0]?.definition.key, 'joy-activities');
assert.equal(unlockedFromComplete[0]?.definition.sessionOrder, 2);
assert.equal(unlockedFromComplete[0]?.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
const joy = items.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joy.unlocked, true);
assert.equal(joy.lockReason, null);
assert.ok(joy.userAssignment?.id);
// Session 3 still locked by cooldown — must NOT block assignment 1
const sessions = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const sessionList = sessions.body.data as Array<{
order: number;
unlocked: boolean;
unlocksAt?: string | null;
}>;
const s3 = sessionList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, false);
assert.ok(s3.unlocksAt);
// Opening cooldown (simulate later unlock of session 3) must not change assignment-1 state
await prisma.sessionProgress.update({
where: { userId_sessionOrder: { userId: user.id, sessionOrder: 2 } },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
const sessionsAfter = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const s3After = (sessionsAfter.body.data as typeof sessionList).find((s) => s.order === 3)!;
assert.equal(s3After.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
const joyAfter = items.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joyAfter.unlocked, true);
assert.equal(joyAfter.userAssignment?.id, joy.userAssignment?.id);
assert.equal(
items.filter((i) => i.unlocked).length,
1,
'unlocking session 3 must not unlock later assignments',
);
// Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6
for (let order = 3; order <= 6; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
await openChatAndComplete(token, order);
}
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
const fifth = items.find((i) => i.definition.displayOrder === 5)!;
assert.equal(fifth.definition.sessionOrder, 6);
assert.equal(fifth.definition.key, 'humor-practice');
assert.equal(fifth.unlocked, true);
assert.ok(fifth.userAssignment?.id);
const sessionsMid = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!;
assert.equal(s7.unlocked, false, 'session 7 may still be on cooldown');
assert.equal(
items.find((i) => i.definition.sessionOrder === 7)?.unlocked,
false,
'assignment for session 7 stays locked until session 7 is completed',
);
});
});
+1 -1
View File
@@ -82,7 +82,7 @@ async function completeSession(token: string, ip: string, order: number, userId:
await prisma.sessionProgress.update({
where: { userId_sessionOrder: { userId, sessionOrder: order } },
data: { completedAt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
return { chatId, progress: complete.body.data, userId };
+10 -3
View File
@@ -106,16 +106,23 @@ describe('profile API', () => {
const underage = await requestJson(app, '/users/me/profile', {
method: 'PATCH',
headers: authHeader(token),
body: JSON.stringify({ name: 'Sara', age: 17 }),
body: JSON.stringify({ name: 'Sara', age: 11 }),
});
assert.equal(underage.status, 422);
const overage = await requestJson(app, '/users/me/profile', {
const teenOk = await requestJson(app, '/users/me/profile', {
method: 'PATCH',
headers: authHeader(token),
body: JSON.stringify({ name: 'Sara', age: 12 }),
});
assert.equal(teenOk.status, 200);
const seniorOk = await requestJson(app, '/users/me/profile', {
method: 'PATCH',
headers: authHeader(token),
body: JSON.stringify({ name: 'Sara', age: 101 }),
});
assert.equal(overage.status, 422);
assert.equal(seniorOk.status, 200);
});
it('does not expose a userId parameter for modifying another user', async (t) => {