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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user