dorfteich/apps/web/e2e/system.spec.ts
Claude Fable 5 69d9072d2c
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m51s
CI / Build container images (pull_request) Successful in 3m4s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#234: retention for mail_outbox
Sent mails were kept forever, and digest bodies name page titles and
actors — an unbounded copy of content-adjacent data. A new daily
mail-outbox-retention job deletes SENT rows (by sentAt) and permanently
FAILED rows (by nextAttemptAt, the last attempt's stamp) once they pass
mail.outboxRetentionDays (instance setting, default 30). PENDING rows —
including failed-but-retryable ones — stay the retry loop's alone.

Decision recorded (security.md §Privacy, residual-risk note for #231):
digest mails keep carrying page titles for now — there is no per-page
classification marking yet to key a suppression on (ADR 0022 / M32
revisits), and a VS-NfD reference configuration can leave SMTP
unconfigured entirely.

Job-count fence in system.spec: 8 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 22:17:28 +02:00

108 lines
4.8 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Site-Admin system panel (issue #86): the maintenance-job table with a
* working, audit-logged manual trigger, the audit viewer finding entries by
* actor, the backup card, and Site-Admin-only access.
*/
test('lists maintenance jobs and triggers one manually', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
const jobsTable = page.locator('.system-jobs__table');
await expect(jobsTable).toBeVisible();
// All registered jobs appear (language-neutral: row count + button).
// Keep in sync with the scheduler registrations: trash-purge,
// version-thinning, page-compaction, data-export-purge,
// notification-digest, orphan-file-sweep (#194), audit-retention (#196),
// conversion-payload-prune (#233), mail-outbox-retention (#234).
await expect(jobsTable.locator('tbody tr')).toHaveCount(9);
const firstRow = jobsTable.locator('tbody tr').first();
await firstRow.getByRole('button').click();
await expect(page.locator('.system-jobs__notice')).toBeVisible();
// A completed run shows truthful last-run data: a date and a duration.
await expect(firstRow.locator('td').nth(2)).not.toHaveText('—');
await expect(firstRow.locator('td').nth(3)).not.toHaveText('—');
// The trigger itself lands in the audit log, attributed to the admin.
const audit = page.locator('.system-audit__table');
await page.locator('.system-audit__filters input[type="text"]').fill('fixture-admin');
await page.locator('.system-audit__filters select').selectOption('job.triggered');
await page.locator('.system-audit__filters button[type="submit"]').click();
await expect(audit.locator('tbody tr').first()).toContainText('job:');
await admin.close();
});
test('the audit viewer finds a grant change by actor', async ({ browser }) => {
// The fixture grant change happens as the shared pond's owner via the API
// (the UI flow itself is covered by #55's pack).
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ponds = await owner.request.get('/api/v1/ponds');
expect(ponds.ok()).toBe(true);
const pond = ((await ponds.json()) as { id: string; type: string }[]).find(
(p) => p.type === 'shared',
);
expect(pond).toBeTruthy();
const me = await owner.request.get('/api/v1/auth/me');
const ownerId = ((await me.json()) as { id: string }).id;
const created = await owner.request.post(`/api/v1/ponds/${pond!.id}/grants`, {
data: {
subjectType: 'user',
subjectId: ownerId,
role: 'reader',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
},
});
expect(created.ok()).toBe(true);
const grantId = ((await created.json()) as { id: string }).id;
await owner.close();
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
await page.locator('.system-audit__filters input[type="text"]').fill('fixture-user');
await page.locator('.system-audit__filters select').selectOption('grant.created');
await page.locator('.system-audit__filters button[type="submit"]').click();
const firstRow = page.locator('.system-audit__table tbody tr').first();
await expect(firstRow).toContainText(`pond:${pond!.id}`);
await expect(firstRow).toContainText('reader');
await admin.close();
// Cleanup so the pack is repeatable without stacking grants.
const cleanup = await contextForUser(browser, BASE_URL, 'fixture-user');
await cleanup.request.delete(`/api/v1/ponds/${pond!.id}/grants/${grantId}`);
await cleanup.close();
});
test('shows the backup card state', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin/system');
// CI stacks run without the sidecar → the unavailable notice; a stage with
// backups shows the freshness badge instead. Either way the card renders.
const card = page.locator('.system-backup');
await expect(card).toBeVisible();
await expect(card.locator('.system-backup__unavailable, .system-badge').first()).toBeVisible();
await admin.close();
});
test('regular users cannot open the system panel', async ({ browser }) => {
const user = await contextForUser(browser, BASE_URL, 'fixture-user');
const page = await user.newPage();
const response = await user.request.get('/api/v1/admin/system/jobs');
expect(response.status()).toBe(403);
await page.goto('/admin/system');
// The RequireSiteAdmin route guard keeps non-admins out of the panel.
await expect(page.locator('.system-jobs__table')).toHaveCount(0);
await user.close();
});