mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
A data wipe during the nightly upload could leave the pre-wipe copy in the bucket: the purge the wipe started removed its request at once, and the upload still in flight checked that request after finishing and found nothing. A wipe's request now stays until a nightly run has walked the account since the wipe. Until then each purge removes only the copies dated up to the wipe's day, so the copy of what the account holds after the wipe stays, and the admin card no longer counts the waiting request as copies still in the bucket.
171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
/**
|
|
* Deleting an account, or wiping its data, takes its off-host copies out of
|
|
* the bucket.
|
|
*
|
|
* The bucket is replaced at the client boundary (`getS3Client`) by an
|
|
* in-memory map; everything above it is real: the route, its transaction, the
|
|
* request row it commits and the purge job that works it. Mutations that must
|
|
* turn this red: drop `requestOffhostPurge` from either route (no request is
|
|
* written and the objects stay), or write the request outside the deletion's
|
|
* transaction and let it fail (the deletion commits without one).
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const bucket = vi.hoisted(() => new Set<string>());
|
|
|
|
vi.mock("@/lib/jobs/offhost-backup", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("@/lib/jobs/offhost-backup")>();
|
|
return {
|
|
...actual,
|
|
getS3Client: vi.fn(async () => ({
|
|
putObject: vi.fn(),
|
|
putStream: vi.fn(),
|
|
getObject: vi.fn(),
|
|
headObject: vi.fn(),
|
|
listObjects: vi.fn(async (prefix: string) =>
|
|
[...bucket].filter((k) => k.startsWith(prefix)).map((key) => ({ key })),
|
|
),
|
|
deleteObject: vi.fn(async (key: string) => {
|
|
bucket.delete(key);
|
|
}),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
bucket.clear();
|
|
process.env.BACKUP_S3_ENDPOINT = "https://r2.example";
|
|
process.env.BACKUP_S3_BUCKET = "hl-backups";
|
|
process.env.BACKUP_S3_ACCESS_KEY = "AKIA";
|
|
process.env.BACKUP_S3_SECRET_KEY = "secret";
|
|
process.env.BACKUP_ENCRYPTION_KEY = "ab".repeat(32);
|
|
});
|
|
|
|
async function seedUserWithSession(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: { username, email: `${username}@example.test` },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user.id;
|
|
}
|
|
|
|
function seedBucket(userId: string, otherId: string) {
|
|
for (const day of ["2026-09-01", "2026-09-02", "2026-09-03"]) {
|
|
bucket.add(`${day}/user-${userId}.json.enc`);
|
|
bucket.add(`${day}/user-${otherId}.json.enc`);
|
|
}
|
|
bucket.add("_healthcheck/1.bin");
|
|
}
|
|
|
|
describe("off-host copies leave with the account", () => {
|
|
it("account deletion removes every copy of that account", async () => {
|
|
const prisma = getPrismaClient();
|
|
const other = await prisma.user.create({
|
|
data: { username: "neighbour", email: "neighbour@example.test" },
|
|
});
|
|
const userId = await seedUserWithSession("leaving");
|
|
seedBucket(userId, other.id);
|
|
|
|
const { DELETE } = await import("@/app/api/settings/account/route");
|
|
const res = await DELETE(
|
|
new NextRequest("http://localhost/api/settings/account", {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const pending = await prisma.offhostPurgeRequest.findMany();
|
|
expect(pending.map((r) => [r.subjectId, r.reason])).toEqual([
|
|
[userId, "account_deleted"],
|
|
]);
|
|
|
|
const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge");
|
|
const report = await processOffhostPurges(prisma);
|
|
expect(report).toMatchObject({ completed: 1, objectsDeleted: 3 });
|
|
expect([...bucket].filter((k) => k.includes(userId))).toEqual([]);
|
|
expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3);
|
|
expect(bucket.has("_healthcheck/1.bin")).toBe(true);
|
|
expect(await prisma.offhostPurgeRequest.count()).toBe(0);
|
|
});
|
|
|
|
it("delete-all-data removes every copy and leaves the receipt", async () => {
|
|
const prisma = getPrismaClient();
|
|
const other = await prisma.user.create({
|
|
data: { username: "neighbour2", email: "neighbour2@example.test" },
|
|
});
|
|
const userId = await seedUserWithSession("wiping");
|
|
seedBucket(userId, other.id);
|
|
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const res = await DELETE(
|
|
new NextRequest("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge");
|
|
await processOffhostPurges(prisma);
|
|
expect([...bucket].filter((k) => k.includes(userId))).toEqual([]);
|
|
expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3);
|
|
const receipt = await prisma.auditLog.findFirst({
|
|
where: { action: "offhost.backup.purged", userId },
|
|
});
|
|
expect(receipt).not.toBeNull();
|
|
|
|
// The request outlives the purge until a nightly run has walked the
|
|
// account since the wipe: the upload in flight at the wipe reads it to
|
|
// throw its pre-wipe copy away.
|
|
expect(await prisma.offhostPurgeRequest.count()).toBe(1);
|
|
await prisma.offhostBackupState.upsert({
|
|
where: { userId },
|
|
create: { userId, lastAttemptAt: new Date(Date.now() + 1000) },
|
|
update: { lastAttemptAt: new Date(Date.now() + 1000) },
|
|
});
|
|
const report = await processOffhostPurges(prisma);
|
|
expect(report).toMatchObject({ completed: 1, awaitingRun: 0 });
|
|
expect(await prisma.offhostPurgeRequest.count()).toBe(0);
|
|
});
|
|
});
|