mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Deleting an account, deleting a managed profile, "Delete all data" and the admin wipe now write an off-host deletion request in the same transaction as the deletion. A new job (offhost-backup-purge, kicked straight away and run nightly as a backstop) lists the bucket once, deletes every <date>/user-<id>.json.enc object of each pending account and removes the request only when nothing of the account is left. A refused delete keeps the request with the bucket's answer and is retried; the off-host card shows how many deletions are outstanding and why. A copy the nightly run was writing while the account was deleted is taken back after the upload. The off-host card also reads the bucket's lifecycle rule and says plainly when there is none (every nightly copy kept forever) or when the credential may not read it. Every stored backup now records the key ids its content was written under, and every off-host upload records the same per key. The admin encryption view lists, per key, the stored copies that need it with the oldest date, and when the last off-host copy needing it expires. The "safe to drop" badge now requires that no backup still needs a retired key, instead of only that every row was rotated. Migration 0358 adds data_backups.inner_key_ids, the key-use table and the deletion requests.
229 lines
7.4 KiB
TypeScript
229 lines
7.4 KiB
TypeScript
/**
|
|
* `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 no listing grant on the bucket 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) },
|
|
offhostPurgeRequest: {
|
|
count: async () => 2,
|
|
findFirst: async () => ({
|
|
requestedAt: new Date("2026-09-20T10:00:00.000Z"),
|
|
lastFailure: "AccessDenied",
|
|
}),
|
|
},
|
|
},
|
|
}));
|
|
|
|
const configuredMock = vi.fn(() => true);
|
|
vi.mock("@/lib/jobs/offhost-backup", () => ({
|
|
offhostBackupConfigured: () => configuredMock(),
|
|
probeOffhostLifecycle: async () => ({
|
|
state: "missing",
|
|
expirationDays: null,
|
|
}),
|
|
}));
|
|
|
|
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: {
|
|
lastAttemptAt: new Date(now - 3 * HOUR),
|
|
lastSuccessAt: new Date(now - 3 * HOUR),
|
|
// The column is BigInt — an object may be 80 GB — and the wire type
|
|
// is a plain number.
|
|
sizeBytes: BigInt(4096),
|
|
},
|
|
},
|
|
{
|
|
id: "u2",
|
|
username: "account-two",
|
|
offhostBackupState: {
|
|
lastAttemptAt: new Date(now - 31 * HOUR),
|
|
lastSuccessAt: new Date(now - 31 * HOUR),
|
|
sizeBytes: BigInt(8192),
|
|
},
|
|
},
|
|
{
|
|
id: "u3",
|
|
username: "account-three",
|
|
offhostBackupState: {
|
|
lastAttemptAt: new Date(now - 100 * HOUR),
|
|
lastSuccessAt: new Date(now - 100 * HOUR),
|
|
sizeBytes: BigInt(128),
|
|
},
|
|
},
|
|
{
|
|
id: "u4",
|
|
username: "account-four",
|
|
// Walked by a run, nothing landed.
|
|
offhostBackupState: {
|
|
lastAttemptAt: new Date(now - 2 * HOUR),
|
|
lastSuccessAt: null,
|
|
sizeBytes: null,
|
|
},
|
|
},
|
|
// No run has recorded this one at all — the state of every account on
|
|
// the morning the ledger ships.
|
|
{ id: "u5", username: "account-five", offhostBackupState: null },
|
|
]);
|
|
|
|
const body = await read();
|
|
|
|
expect(body.offhost.configured).toBe(true);
|
|
expect(body.offhost.periodHours).toBe(24);
|
|
// Worst first, alphabetical within a verdict. The database hands them
|
|
// back by username; the one account that needs attention must not be
|
|
// wherever the alphabet leaves it on a hundred-account cohort.
|
|
expect(
|
|
body.offhost.rows.map((row) => [row.username, row.freshness]),
|
|
).toEqual([
|
|
["account-three", "stale"],
|
|
["account-two", "due"],
|
|
["account-four", "never"],
|
|
["account-five", "unknown"],
|
|
["account-one", "fresh"],
|
|
]);
|
|
const byName = new Map(body.offhost.rows.map((row) => [row.username, row]));
|
|
expect(byName.get("account-one")?.sizeBytes).toBe(4096);
|
|
expect(typeof byName.get("account-one")?.sizeBytes).toBe("number");
|
|
expect(byName.get("account-one")?.ageHours).toBe(3);
|
|
expect(byName.get("account-four")?.lastSuccessAt).toBeNull();
|
|
expect(byName.get("account-four")?.sizeBytes).toBeNull();
|
|
expect(byName.get("account-four")?.lastAttemptAt).not.toBeNull();
|
|
// `unknown` is the honest one: the ledger has no history for this
|
|
// account, which is not the same claim as "the bucket is empty".
|
|
expect(byName.get("account-five")?.lastAttemptAt).toBeNull();
|
|
});
|
|
|
|
it("says so when neither the environment nor the ledger knows of any upload", async () => {
|
|
configuredMock.mockReturnValue(false);
|
|
findManyMock.mockResolvedValue([
|
|
{ id: "u1", username: "account-one", offhostBackupState: null },
|
|
]);
|
|
|
|
const body = await read();
|
|
|
|
expect(body.offhost).toMatchObject({
|
|
configured: false,
|
|
periodHours: 24,
|
|
rows: [],
|
|
// No bucket to ask on a host that is not configured.
|
|
lifecycle: { state: "unknown", expirationDays: null },
|
|
});
|
|
});
|
|
|
|
it("lets a ledger row outrank this process's own environment", async () => {
|
|
// A hand-rolled split deployment can give BACKUP_S3_* to the worker and
|
|
// not to the web process. The rows are proof uploads are happening;
|
|
// saying "nothing leaves this host" over them would be this process
|
|
// reporting on a job it cannot see.
|
|
configuredMock.mockReturnValue(false);
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "u1",
|
|
username: "account-one",
|
|
offhostBackupState: {
|
|
lastAttemptAt: new Date(),
|
|
lastSuccessAt: new Date(),
|
|
sizeBytes: BigInt(4096),
|
|
},
|
|
},
|
|
]);
|
|
|
|
const body = await read();
|
|
|
|
expect(body.offhost.configured).toBe(true);
|
|
expect(body.offhost.rows.map((row) => row.freshness)).toEqual(["fresh"]);
|
|
});
|
|
|
|
it("never ships a credential or an object key with the verdicts", async () => {
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "u1",
|
|
username: "account-one",
|
|
offhostBackupState: {
|
|
lastAttemptAt: new Date(),
|
|
lastSuccessAt: new Date(),
|
|
sizeBytes: BigInt(1),
|
|
},
|
|
},
|
|
]);
|
|
|
|
const body = await read();
|
|
|
|
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
|
|
"ageHours",
|
|
"freshness",
|
|
"lastAttemptAt",
|
|
"lastSuccessAt",
|
|
"sizeBytes",
|
|
"userId",
|
|
"username",
|
|
]);
|
|
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
|
|
});
|
|
|
|
it("carries the bucket's lifecycle verdict and the deletions it still owes", async () => {
|
|
configuredMock.mockReturnValue(true);
|
|
queryRawMock.mockResolvedValue([]);
|
|
findManyMock.mockResolvedValue([]);
|
|
const body = await read();
|
|
expect(body.offhost.lifecycle).toEqual({
|
|
state: "missing",
|
|
expirationDays: null,
|
|
});
|
|
expect(body.offhost.pendingDeletions).toEqual({
|
|
count: 2,
|
|
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
|
lastFailure: "AccessDenied",
|
|
});
|
|
});
|
|
});
|