Convert read_events to monthly RANGE partitions on occurred_at, with a DEFAULT partition as safety net: a lagging maintenance job must never turn the trail's hard-failure semantics into an outage for classified reads. The dedup unique pair (#223) moves to per-partition indexes (PostgreSQL cannot carry it on the parent); a bucket spanning a month boundary may record one duplicate — over-recording is acceptable, gaps are not. New daily job read-trail-maintenance (job-count fence 9 -> 10) creates months ahead — each with its dedup index — and applies the trail's own retention readTrail.retentionDays (default 365, deliberately independent of audit.retentionDays): whole expired months are DROPped without scanning, remainders deleted by range, every run audited as read_trail.pruned (catalogue v1.2; the fence regex now admits an underscore namespace). Site-Admin query path GET /admin/system/read-events answers "who read page X" and "what did user Y read" within a period — API-only by design, documented. Growth measured and documented in data-model.md: ~1 MB per 1000 events including indexes. Tests: retention pruning + audited deletion + admin queries on the shared database; the partitioned shape, per-partition P2002 dedup, months-ahead creation and DROP-based pruning against a fresh database built by the real migration chain. Refs #224. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
109 lines
4.8 KiB
TypeScript
109 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),
|
|
// read-trail-maintenance (#224).
|
|
await expect(jobsTable.locator('tbody tr')).toHaveCount(10);
|
|
|
|
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();
|
|
});
|