dorfteich/apps/api/src/admin/system-admin.e2e.db.test.ts
Claude Fable 5 c8aac13dfb
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
Add Site-Admin system panel with persistent audit trail (#86)
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00

259 lines
9.3 KiB
TypeScript

import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import {
BACKUP_STATUS_FILE,
type AuditListView,
type BackupStatus,
type JobTriggerResult,
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
} from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GrantsService } from '../grants/grants.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const HOUR = 3_600_000;
function statusFixture(finishedAt: string): BackupStatus {
return {
schemaVersion: 1,
updatedAt: finishedAt,
retentionDays: 7,
lastRun: {
backupId: '20260711-030000',
startedAt: finishedAt,
finishedAt,
durationMs: 1200,
outcome: 'succeeded',
sizes: { dumpBytes: 100, archiveBytes: 200 },
},
lastSuccess: {
backupId: '20260711-030000',
finishedAt,
sizes: { dumpBytes: 100, archiveBytes: 200 },
},
};
}
/**
* Site-Admin system panel end to end (issue #86): registered jobs with
* truthful last-run data, an audit-logged manual trigger, the backup card
* mirroring status.json, the audit viewer finding a grant change by actor,
* and the storage top list — all Site-Admin-only.
*/
describe.skipIf(!hasTestDb)('system admin panel (e2e, issue #86)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let backupsDir: string;
const suffix = uniqueSuffix();
const password = 'systempanel ist wachsam 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const pondIds: string[] = [];
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
const users = app.get(UsersService);
const username = `sys-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Sys ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
if (siteAdmin)
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
ids[handle] = user.id;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
beforeAll(async () => {
backupsDir = mkdtempSync(join(tmpdir(), 'dorfteich-system-backups-'));
process.env.BACKUPS_DIR = backupsDir;
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('admin', true);
await makeUser('user', false);
});
afterAll(async () => {
delete process.env.BACKUPS_DIR;
const all = Object.values(ids);
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('lists every registered maintenance job', async () => {
const res = await api().get('/api/v1/admin/system/jobs').set('Cookie', cookies.admin!);
expect(res.status).toBe(200);
const jobs = res.body as SystemJobView[];
const names = jobs.map((job) => job.name);
for (const expected of [
'trash-purge',
'version-thinning',
'page-compaction',
'data-export-purge',
]) {
expect(names).toContain(expected);
}
for (const job of jobs) {
expect(job.cadenceSeconds).toBeGreaterThan(0);
}
});
it('manually triggers a job, records truthful run data, and audit-logs itself', async () => {
const res = await api()
.post('/api/v1/admin/system/jobs/trash-purge/run')
.set('Cookie', cookies.admin!)
.expect(201);
const result = res.body as JobTriggerResult;
expect(result.outcome).toBe('succeeded');
expect(result.job.status).toBe('IDLE');
expect(result.job.lastRunAt).not.toBeNull();
expect(result.job.lastDurationMs).not.toBeNull();
const audit = await api()
.get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=job.triggered`)
.set('Cookie', cookies.admin!)
.expect(200);
const list = audit.body as AuditListView;
expect(list.total).toBeGreaterThan(0);
expect(list.entries[0]).toMatchObject({
action: 'job.triggered',
targetType: 'job',
targetId: 'trash-purge',
details: { outcome: 'succeeded' },
});
expect(list.entries[0]?.actor?.username).toBe(`sys-admin-${suffix}`);
});
it('rejects triggering an unknown job', async () => {
await api()
.post('/api/v1/admin/system/jobs/no-such-job/run')
.set('Cookie', cookies.admin!)
.expect(404);
});
it('finds a grant change through the audit viewer, filtered by actor', async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } });
const target = await prisma.user.findUniqueOrThrow({ where: { id: ids.user! } });
const pond = await prisma.pond.create({
data: { slug: `sys-pond-${suffix}`, name: 'Sys Pond', type: 'SHARED', ownerId: admin.id },
});
pondIds.push(pond.id);
await app.get(GrantsService).createGrant(admin, pond.id, {
subjectType: 'user',
subjectId: target.id,
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
});
const res = await api()
.get(`/api/v1/admin/system/audit?actor=sys-admin-${suffix}&action=grant.created`)
.set('Cookie', cookies.admin!)
.expect(200);
const list = res.body as AuditListView;
expect(list.total).toBe(1);
expect(list.entries[0]).toMatchObject({
action: 'grant.created',
targetType: 'pond',
targetId: pond.id,
details: expect.objectContaining({ role: 'editor', subjectId: target.id }),
});
// Filtering by a different actor does not surface it.
const other = await api()
.get(`/api/v1/admin/system/audit?actor=sys-user-${suffix}&action=grant.created`)
.set('Cookie', cookies.admin!)
.expect(200);
expect((other.body as AuditListView).total).toBe(0);
});
it('mirrors status.json in the backup card, including staleness', async () => {
const stale = new Date(Date.now() - 40 * HOUR).toISOString();
writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(stale)));
const staleRes = await api()
.get('/api/v1/admin/system/backup')
.set('Cookie', cookies.admin!)
.expect(200);
const staleView = staleRes.body as SystemBackupView;
expect(staleView).toMatchObject({ available: true, fresh: false, maxAgeHours: 26 });
expect(staleView.status?.lastSuccess?.backupId).toBe('20260711-030000');
const freshAt = new Date(Date.now() - 2 * HOUR).toISOString();
writeFileSync(join(backupsDir, BACKUP_STATUS_FILE), JSON.stringify(statusFixture(freshAt)));
const freshRes = await api()
.get('/api/v1/admin/system/backup')
.set('Cookie', cookies.admin!)
.expect(200);
expect((freshRes.body as SystemBackupView).fresh).toBe(true);
});
it('lists the largest ponds in the storage overview', async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { id: ids.admin! } });
const big = await prisma.pond.create({
data: { slug: `sys-big-${suffix}`, name: 'Big Pond', type: 'SHARED', ownerId: admin.id },
});
const small = await prisma.pond.create({
data: { slug: `sys-small-${suffix}`, name: 'Small Pond', type: 'SHARED', ownerId: admin.id },
});
pondIds.push(big.id, small.id);
await prisma.pondUsage.create({ data: { pondId: big.id, storageBytesUsed: 5_000_000n } });
await prisma.pondUsage.create({ data: { pondId: small.id, storageBytesUsed: 1_000n } });
const res = await api()
.get('/api/v1/admin/system/storage')
.set('Cookie', cookies.admin!)
.expect(200);
const view = res.body as StorageOverviewView;
const bigIndex = view.ponds.findIndex((p) => p.pondId === big.id);
const smallIndex = view.ponds.findIndex((p) => p.pondId === small.id);
expect(bigIndex).toBeGreaterThanOrEqual(0);
expect(view.ponds[bigIndex]?.storageBytesUsed).toBe(5_000_000);
if (smallIndex >= 0) expect(bigIndex).toBeLessThan(smallIndex);
expect(view.totalBytes).toBeGreaterThanOrEqual(5_000_000);
});
it('is Site-Admin-only', async () => {
for (const path of [
'/api/v1/admin/system/jobs',
'/api/v1/admin/system/backup',
'/api/v1/admin/system/audit',
'/api/v1/admin/system/storage',
]) {
await api().get(path).set('Cookie', cookies.user!).expect(403);
}
await api()
.post('/api/v1/admin/system/jobs/trash-purge/run')
.set('Cookie', cookies.user!)
.expect(403);
});
});