feat(admin): say per account how fresh the off-host copy is

The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.

The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.

The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 09:27:58 +02:00
1 parent 348d9e0739
commit 1c10bbbde8
23 files changed
+797 -10

No files matched your search

@@ -0,0 +1,146 @@
/**
* `GET /api/admin/backups` — the off-host leg.
*
* The rows the weekly pass writes into this database were already covered by
* the page that lists them. What was not covered is the question this response
* now answers: per account, when did an encrypted copy last reach the
* operator's bucket, and is that inside what the nightly schedule promises.
*
* The bucket is never touched here, and that is the assertion that matters —
* the verdicts come from the ledger the worker writes, so an operator whose
* worker holds PutObject and nothing else still gets a true answer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/jobs/job-failures", () => ({
readLastQueueRun: vi.fn(async () => null),
}));
const queryRawMock = vi.fn();
const findManyMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
},
}));
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
offhostBackupConfigured: () => configuredMock(),
}));
import { GET } from "../route";
import type { BackupsList } from "@/types/backups";
const HOUR = 3_600_000;
async function read(): Promise<BackupsList> {
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
return (await res.json()).data as BackupsList;
}
beforeEach(() => {
vi.clearAllMocks();
configuredMock.mockReturnValue(true);
queryRawMock.mockResolvedValue([]);
findManyMock.mockResolvedValue([]);
});
describe("GET /api/admin/backups — off-host freshness", () => {
it("rates every account against the nightly schedule without listing the bucket", async () => {
const now = Date.now();
findManyMock.mockResolvedValue([
{
id: "u1",
username: "account-one",
offhostBackupState: {
lastSuccessAt: new Date(now - 3 * HOUR),
sizeBytes: 4096,
},
},
{
id: "u2",
username: "account-two",
offhostBackupState: {
lastSuccessAt: new Date(now - 30 * HOUR),
sizeBytes: 8192,
},
},
{
id: "u3",
username: "account-three",
offhostBackupState: {
lastSuccessAt: new Date(now - 100 * HOUR),
sizeBytes: 128,
},
},
{ id: "u4", username: "account-four", offhostBackupState: null },
]);
const body = await read();
expect(body.offhost.configured).toBe(true);
expect(body.offhost.periodHours).toBe(24);
expect(
body.offhost.rows.map((row) => [row.username, row.freshness]),
).toEqual([
["account-one", "fresh"],
["account-two", "due"],
["account-three", "stale"],
["account-four", "never"],
]);
expect(body.offhost.rows[0]?.sizeBytes).toBe(4096);
expect(body.offhost.rows[0]?.ageHours).toBe(3);
expect(body.offhost.rows[3]?.lastSuccessAt).toBeNull();
expect(body.offhost.rows[3]?.sizeBytes).toBeNull();
});
it("says so and reads no account when off-host backup is not configured", async () => {
configuredMock.mockReturnValue(false);
const body = await read();
expect(body.offhost).toEqual({
configured: false,
periodHours: 24,
rows: [],
});
// Not merely empty output — the query never runs, so an unconfigured host
// does not pay for a table scan to be told nothing is set up.
expect(findManyMock).not.toHaveBeenCalled();
});
it("never ships a credential or an object key with the verdicts", async () => {
findManyMock.mockResolvedValue([
{
id: "u1",
username: "account-one",
offhostBackupState: {
lastSuccessAt: new Date(),
sizeBytes: 1,
},
},
]);
const body = await read();
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
"ageHours",
"freshness",
"lastSuccessAt",
"sizeBytes",
"userId",
"username",
]);
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
});
});
+58 -2
View File
@@ -13,6 +13,12 @@
* Without those two the page cannot tell a working backup from one that
* stopped six weeks ago — every row it lists has a perfectly ordinary
* timestamp either way.
*
* And alongside both, the off-host leg: one row per account saying when this
* host last put that account's encrypted copy in the operator's bucket and how
* stale that is against the nightly schedule. It comes from the ledger the
* worker writes, never from a bucket listing — the answer has to hold on a host
* whose worker grant is PutObject and nothing else.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
@@ -21,10 +27,19 @@ import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
} from "@/lib/jobs/offhost-backup-freshness";
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
// so callers (in particular `components/admin/backups-section.tsx`) don't
// have to reach across the component → route-handler layer boundary.
import type { BackupRow, BackupsList } from "@/types/backups";
import type {
BackupRow,
BackupsList,
OffhostAccountRow,
} from "@/types/backups";
export const dynamic = "force-dynamic";
@@ -67,6 +82,42 @@ export const GET = apiHandler(async () => {
createdAt: row.created_at.toISOString(),
}));
// The off-host leg, per account, read from the ledger the nightly worker
// writes rather than from the bucket. The page must be able to answer "which
// account has no recent copy off-host" on a host whose worker grant is
// PutObject and nothing else, and a listing call from a page render would
// put the operator's credentials on the request path of every page view.
const now = new Date();
const configured = offhostBackupConfigured();
const offhostAccounts = configured
? await prisma.user.findMany({
select: {
id: true,
username: true,
offhostBackupState: {
select: { lastSuccessAt: true, sizeBytes: true },
},
},
orderBy: { username: "asc" },
})
: [];
const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => {
const state = account.offhostBackupState;
const verdict = classifyOffhostBackup({
lastSuccessAt: state?.lastSuccessAt ?? null,
now,
});
return {
userId: account.id,
username: account.username,
lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null,
sizeBytes: state?.sizeBytes ?? null,
ageHours: verdict.ageHours,
freshness: verdict.freshness,
};
});
const payload: BackupsList = {
rows: list,
// Matches the retention window the backup-prune job enforces so the
@@ -77,8 +128,13 @@ export const GET = apiHandler(async () => {
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
.map((row) => row.created_at),
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
now: new Date(),
now,
}),
offhost: {
configured,
periodHours: OFFHOST_BACKUP_PERIOD_HOURS,
rows: offhostRows,
},
};
return apiSuccess(payload);