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 = {}; const cookies: Record = {}; const pondIds: string[] = []; const api = () => request(app.getHttpServer()); async function makeUser(handle: string, siteAdmin: boolean): Promise { 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); }); });