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
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
259 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|