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>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|