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>
511 lines
17 KiB
TypeScript
511 lines
17 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|