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