241 lines
7.8 KiB
TypeScript
241 lines
7.8 KiB
TypeScript
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/);
|
||
|
|
});
|
||
|
|
});
|