mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
A restore never runs beside a backup pass or another large job: the heavy lane puts its delivery back in the queue for a minute at a time. The job's own row says queued for the whole wait, and the console said the restore starts "as soon as the background worker picks it up", which read as a stuck worker while a backup ran. The status routes now ask the queue. A delivery the lane put back sits in created with a start_after ahead of now, and the active row of a heavy queue is what holds the lane. The job view carries waiting: "backup" | "busy" | null, and the console says it waits for the running backup, or for another large job, and since when it has been queued, in all nine locales. A queue that cannot be asked answers no wait and is reported at warn. The job view and its reads move to backup-restore-view.ts, which keeps the worker module under its size baseline.
695 lines
24 KiB
TypeScript
695 lines
24 KiB
TypeScript
/**
|
|
* #1031 — a stored backup kept in pieces.
|
|
*
|
|
* v1.39.1 wrote a backup a piece at a time but still joined the pieces into
|
|
* one value in `data_backups.data`, and one value has to pass through the app
|
|
* whole. So a copy could be at most a fifth of the heap, 105 MB in a 1 GB
|
|
* container, and an account with 1.75 million readings could not be backed up
|
|
* at all. A copy is now a run of sealed pieces in `data_backup_chunks`, and
|
|
* this file pins what that has to keep true:
|
|
*
|
|
* - the weekly copy restores exactly, and the size it lists is what it takes;
|
|
* - a piece dropped, moved, altered or cut off the end is refused, and the
|
|
* refusal comes before the restore deletes anything;
|
|
* - a copy stored in one value by an earlier release still restores;
|
|
* - a run that fails halfway leaves the previous copy whole and readable;
|
|
* - replacing, wiping or deleting a copy takes its pieces with it.
|
|
*
|
|
* The pieces are made small here (`chunkBytes`) so a record that fits a test
|
|
* run still spans many of them.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { readStoredBackup } from "./stored-backup-read";
|
|
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { restoreBackup } from "@/lib/export/restore-backup";
|
|
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
|
import { STORED_BACKUP_SELECT } from "@/lib/export/stored-backup";
|
|
|
|
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),
|
|
}));
|
|
|
|
const COUNT = 12_000;
|
|
/** Small pieces, so a test-sized record spans many of them. */
|
|
const SMALL = { chunkBytes: 4 * 1024 };
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedAccount() {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "chunked-owner",
|
|
email: "chunked-owner@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
const start = Date.UTC(2024, 0, 1);
|
|
await prisma.measurement.createMany({
|
|
data: Array.from({ length: COUNT }, (_, i) => ({
|
|
id: `m-${String(i).padStart(5, "0")}`,
|
|
userId: user.id,
|
|
type: i % 2 === 0 ? ("PULSE" as const) : ("WEIGHT" as const),
|
|
value: 50 + (i % 40) + 0.5,
|
|
unit: i % 2 === 0 ? "bpm" : "kg",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(start + i * 600_000),
|
|
externalId: `hk-${i}`,
|
|
})),
|
|
});
|
|
return user;
|
|
}
|
|
|
|
async function measurementsOf(userId: string) {
|
|
return getPrismaClient().measurement.findMany({
|
|
where: { userId },
|
|
orderBy: { id: "asc" },
|
|
omit: { createdAt: true, updatedAt: true },
|
|
});
|
|
}
|
|
|
|
/** The weekly job's own write, with small pieces. */
|
|
async function weeklyCopy(
|
|
userId: string,
|
|
options: { chunkBytes: number; maxBytes?: number } = SMALL,
|
|
) {
|
|
const prisma = getPrismaClient();
|
|
return storeBackupBlob(
|
|
prisma,
|
|
{ userId, type: "WEEKLY_AUTO" },
|
|
(write) =>
|
|
streamFullBackupJson(prisma, userId, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
options,
|
|
);
|
|
}
|
|
|
|
async function restoreRoute(id: string) {
|
|
const { POST } = await import("./restore-job-driver");
|
|
return POST(
|
|
new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
}
|
|
|
|
/** The restore itself, without the route's admission check in front of it. */
|
|
async function restoreDirect(id: string, actorUserId: string) {
|
|
const backup = await getPrismaClient().dataBackup.findUniqueOrThrow({
|
|
where: { id },
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
return restoreBackup({
|
|
backup,
|
|
actorUserId,
|
|
ipAddress: null,
|
|
restoreInstanceSettings: false,
|
|
});
|
|
}
|
|
|
|
async function chunksOf(backupId: string) {
|
|
return getPrismaClient().dataBackupChunk.findMany({
|
|
where: { backupId },
|
|
orderBy: { seq: "asc" },
|
|
});
|
|
}
|
|
|
|
describe("a backup stored in pieces", () => {
|
|
it("restores every measurement exactly, from many pieces", async () => {
|
|
const user = await seedAccount();
|
|
const before = await measurementsOf(user.id);
|
|
const { id, chunks } = await weeklyCopy(user.id);
|
|
expect(chunks).toBeGreaterThan(5);
|
|
|
|
const prisma = getPrismaClient();
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({ where: { id } });
|
|
expect(row.data).toBeNull();
|
|
expect(row.chunkCount).toBe(chunks);
|
|
expect(await chunksOf(id)).toHaveLength(chunks);
|
|
|
|
await prisma.measurement.deleteMany({ where: { userId: user.id } });
|
|
const res = await restoreRoute(id);
|
|
expect(res.status, JSON.stringify(await res.clone().json())).toBe(200);
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
});
|
|
|
|
it("previews and downloads the copy the job stored", async () => {
|
|
const user = await seedAccount();
|
|
const { id } = await weeklyCopy(user.id);
|
|
const params = { params: Promise.resolve({ id }) };
|
|
|
|
const { GET: summary } =
|
|
await import("@/app/api/admin/backups/[id]/summary/route");
|
|
const preview = await summary(new Request("http://x") as never, params);
|
|
expect(preview.status).toBe(200);
|
|
const { data } = (await preview.json()) as {
|
|
data: { summary: { measurements: number } };
|
|
};
|
|
expect(data.summary.measurements).toBe(COUNT);
|
|
|
|
const { GET: download } =
|
|
await import("@/app/api/admin/backups/[id]/download/route");
|
|
const file = await download(new Request("http://x") as never, params);
|
|
expect(file.status).toBe(200);
|
|
const text = await file.text();
|
|
expect(Number(file.headers.get("content-length"))).toBe(
|
|
Buffer.byteLength(text),
|
|
);
|
|
expect(text).toBe(await readStoredBackup(getPrismaClient(), id));
|
|
expect(JSON.parse(text).measurements).toHaveLength(COUNT);
|
|
});
|
|
|
|
it("lists the size the copy takes in the database, in either form", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const { id, bytes } = await weeklyCopy(user.id);
|
|
const legacy = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "MANUAL_UPLOAD_1",
|
|
data: await legacyStreamedBlobFrom((write) =>
|
|
streamFullBackupJson(prisma, user.id, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
),
|
|
},
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/admin/backups/route");
|
|
const res = await GET();
|
|
const { data } = (await res.json()) as {
|
|
data: { rows: Array<{ id: string; sizeBytes: number }> };
|
|
};
|
|
const sizes = new Map(data.rows.map((b) => [b.id, b.sizeBytes]));
|
|
expect(sizes.get(id)).toBe(bytes);
|
|
expect(sizes.get(legacy.id)).toBe(legacy.data!.length);
|
|
});
|
|
});
|
|
|
|
describe("a stored copy whose pieces do not add up is refused before anything is deleted", () => {
|
|
type Tamper = (backupId: string) => Promise<void>;
|
|
const prisma = () => getPrismaClient();
|
|
|
|
const cases: Array<[string, Tamper]> = [
|
|
[
|
|
"a piece dropped from the middle",
|
|
async (backupId) => {
|
|
await prisma().dataBackupChunk.deleteMany({
|
|
where: { backupId, seq: 2 },
|
|
});
|
|
},
|
|
],
|
|
[
|
|
"two pieces swapped",
|
|
async (backupId) => {
|
|
const [a, b] = [
|
|
await prisma().dataBackupChunk.findUniqueOrThrow({
|
|
where: { backupId_seq: { backupId, seq: 1 } },
|
|
}),
|
|
await prisma().dataBackupChunk.findUniqueOrThrow({
|
|
where: { backupId_seq: { backupId, seq: 2 } },
|
|
}),
|
|
];
|
|
await prisma().dataBackupChunk.update({
|
|
where: { id: a.id },
|
|
data: { data: b.data },
|
|
});
|
|
await prisma().dataBackupChunk.update({
|
|
where: { id: b.id },
|
|
data: { data: a.data },
|
|
});
|
|
},
|
|
],
|
|
[
|
|
"one byte of a piece altered",
|
|
async (backupId) => {
|
|
const piece = await prisma().dataBackupChunk.findUniqueOrThrow({
|
|
where: { backupId_seq: { backupId, seq: 3 } },
|
|
});
|
|
const altered = new Uint8Array(piece.data);
|
|
altered[altered.byteLength - 20]! ^= 0x01;
|
|
await prisma().dataBackupChunk.update({
|
|
where: { id: piece.id },
|
|
data: { data: altered },
|
|
});
|
|
},
|
|
],
|
|
[
|
|
"the last piece cut off, and the count lowered to match",
|
|
async (backupId) => {
|
|
const row = await prisma().dataBackup.findUniqueOrThrow({
|
|
where: { id: backupId },
|
|
});
|
|
const last = row.chunkCount! - 1;
|
|
await prisma().dataBackupChunk.deleteMany({
|
|
where: { backupId, seq: last },
|
|
});
|
|
await prisma().dataBackup.update({
|
|
where: { id: backupId },
|
|
data: { chunkCount: last },
|
|
});
|
|
},
|
|
],
|
|
];
|
|
|
|
for (const [name, tamper] of cases) {
|
|
it(`${name}: the route refuses, the job refuses, nothing changes`, async () => {
|
|
const user = await seedAccount();
|
|
const { id } = await weeklyCopy(user.id);
|
|
await tamper(id);
|
|
const before = await measurementsOf(user.id);
|
|
expect(before).toHaveLength(COUNT);
|
|
|
|
const res = await restoreRoute(id);
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
|
expect(body.meta?.errorCode).toBe("backup.payload.undecryptable");
|
|
|
|
// The job refuses on its own too, for a copy tampered with after the
|
|
// route admitted it.
|
|
const outcome = await restoreDirect(id, user.id);
|
|
expect(outcome.ok).toBe(false);
|
|
if (!outcome.ok)
|
|
expect(outcome.code).toBe("backup.payload.undecryptable");
|
|
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
});
|
|
}
|
|
|
|
it("a piece from another account's copy put in its place", async () => {
|
|
const user = await seedAccount();
|
|
const other = await getPrismaClient().user.create({
|
|
data: { username: "other", email: "other@example.test" },
|
|
});
|
|
await getPrismaClient().measurement.createMany({
|
|
data: Array.from({ length: COUNT }, (_, i) => ({
|
|
userId: other.id,
|
|
type: "PULSE" as const,
|
|
value: 70,
|
|
unit: "bpm",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(Date.UTC(2025, 0, 1) + i * 60_000),
|
|
})),
|
|
});
|
|
const mine = await weeklyCopy(user.id);
|
|
const theirs = await weeklyCopy(other.id);
|
|
const foreign = await getPrismaClient().dataBackupChunk.findUniqueOrThrow({
|
|
where: { backupId_seq: { backupId: theirs.id, seq: 1 } },
|
|
});
|
|
await getPrismaClient().dataBackupChunk.update({
|
|
where: { backupId_seq: { backupId: mine.id, seq: 1 } },
|
|
data: { data: foreign.data },
|
|
});
|
|
const before = await measurementsOf(user.id);
|
|
|
|
expect((await restoreRoute(mine.id)).status).toBe(422);
|
|
const outcome = await restoreDirect(mine.id, user.id);
|
|
expect(outcome.ok).toBe(false);
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
});
|
|
|
|
it("the preview and the download refuse the same copy with the same answer", async () => {
|
|
const user = await seedAccount();
|
|
const { id } = await weeklyCopy(user.id);
|
|
await getPrismaClient().dataBackupChunk.deleteMany({
|
|
where: { backupId: id, seq: 1 },
|
|
});
|
|
const params = { params: Promise.resolve({ id }) };
|
|
const { GET: summary } =
|
|
await import("@/app/api/admin/backups/[id]/summary/route");
|
|
const { GET: download } =
|
|
await import("@/app/api/admin/backups/[id]/download/route");
|
|
for (const res of [
|
|
await summary(new Request("http://x") as never, params),
|
|
await download(new Request("http://x") as never, params),
|
|
]) {
|
|
expect(res.status).toBe(422);
|
|
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
|
expect(body.meta?.errorCode).toBe("backup.payload.undecryptable");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("an uploaded backup file", () => {
|
|
it("is stored in pieces under the account the file names, not the admin who sent it", async () => {
|
|
const admin = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const owner = await prisma.user.create({
|
|
data: { username: "file-owner", email: "file-owner@example.test" },
|
|
});
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: owner.id,
|
|
type: "WEIGHT",
|
|
value: 71.5,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date(Date.UTC(2025, 5, 1)),
|
|
},
|
|
});
|
|
const weekly = await weeklyCopy(owner.id);
|
|
const file = await readStoredBackup(prisma, weekly.id);
|
|
|
|
const { POST: upload } =
|
|
await import("@/app/api/admin/backups/upload/route");
|
|
const res = await upload(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: file,
|
|
}) as never,
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const { data } = (await res.json()) as { data: { id: string } };
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: data.id },
|
|
});
|
|
expect(row.userId).toBe(owner.id);
|
|
expect(row.userId).not.toBe(admin.id);
|
|
expect(row.data).toBeNull();
|
|
expect(await readStoredBackup(prisma, data.id)).toBe(file);
|
|
});
|
|
});
|
|
|
|
describe("a copy stored in one value by an earlier release", () => {
|
|
it("still restores exactly", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const before = await measurementsOf(user.id);
|
|
const legacy = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEEKLY_AUTO",
|
|
data: await legacyStreamedBlobFrom((write) =>
|
|
streamFullBackupJson(prisma, user.id, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
),
|
|
},
|
|
});
|
|
await prisma.measurement.deleteMany({ where: { userId: user.id } });
|
|
|
|
expect((await restoreRoute(legacy.id)).status).toBe(200);
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
});
|
|
|
|
it("is replaced in pieces by the next weekly run", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const legacy = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEEKLY_AUTO",
|
|
data: await legacyStreamedBlobFrom(async (write) => {
|
|
await write("{}");
|
|
}),
|
|
},
|
|
});
|
|
const { id, chunks } = await weeklyCopy(user.id);
|
|
expect(id).toBe(legacy.id);
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({ where: { id } });
|
|
expect(row.data).toBeNull();
|
|
expect(row.chunkCount).toBe(chunks);
|
|
});
|
|
});
|
|
|
|
describe("a restore queued against a copy that is then replaced", () => {
|
|
it("is refused as backup_changed, and nothing is deleted", async () => {
|
|
const user = await seedAccount();
|
|
const { id } = await weeklyCopy(user.id);
|
|
const { acceptingBoss } = await import("./restore-job-driver");
|
|
const { POST: queueRestore } =
|
|
await import("@/app/api/admin/backups/[id]/restore/route");
|
|
const { getGlobalBoss, setGlobalBoss } =
|
|
await import("@/lib/jobs/boss-instance");
|
|
const { runBackupRestoreJob } = await import("@/lib/jobs/backup-restore");
|
|
const { readBackupRestoreJob } =
|
|
await import("@/lib/jobs/backup-restore-view");
|
|
|
|
const previous = getGlobalBoss();
|
|
setGlobalBoss(acceptingBoss);
|
|
let queued: Response;
|
|
try {
|
|
queued = await queueRestore(
|
|
new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
} finally {
|
|
setGlobalBoss(previous);
|
|
}
|
|
expect(queued.status).toBe(202);
|
|
const { data } = (await queued.json()) as { data: { jobId: string } };
|
|
|
|
// The weekly run leaves a copy alone while a restore of it is queued, so
|
|
// stand in for a replacement written anyway (an older worker, say): the
|
|
// copy's stream id changes.
|
|
await expect(weeklyCopy(user.id)).rejects.toThrow(/restore of the current/);
|
|
await getPrismaClient().dataBackup.update({
|
|
where: { id },
|
|
data: { chunkStreamId: "f".repeat(32) },
|
|
});
|
|
const before = await measurementsOf(user.id);
|
|
await runBackupRestoreJob(data.jobId).catch(() => undefined);
|
|
const job = await readBackupRestoreJob(data.jobId);
|
|
expect(job?.failure?.code).toBe("backup_changed");
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("a copy replaced while it is being read", () => {
|
|
it("says it was replaced, not that it was tampered with", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const { id } = await weeklyCopy(user.id);
|
|
const backup = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id },
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const { openStoredBackup, BackupReplacedError } =
|
|
await import("@/lib/export/stored-backup");
|
|
const source = await openStoredBackup(prisma, backup);
|
|
|
|
// The next weekly run writes a new copy into the same row.
|
|
await weeklyCopy(user.id);
|
|
|
|
const read = async () => {
|
|
for await (const chunk of source()) void chunk;
|
|
};
|
|
const err = await read().catch((e: unknown) => e);
|
|
expect(err).toBeInstanceOf(BackupReplacedError);
|
|
expect((err as Error).message).toMatch(
|
|
/replaced by a newer one while it was being read/,
|
|
);
|
|
});
|
|
|
|
it("is not replaced by the weekly run while a restore of it is queued or running", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const first = await weeklyCopy(user.id);
|
|
const before = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: first.id },
|
|
});
|
|
for (const status of ["queued", "running"]) {
|
|
const job = await prisma.backupRestoreJob.create({
|
|
data: {
|
|
userId: user.id,
|
|
actorUserId: user.id,
|
|
backupId: first.id,
|
|
backupDigest: "x",
|
|
status,
|
|
},
|
|
});
|
|
const { BackupBusyError } = await import("@/lib/export/backup-blob");
|
|
await expect(weeklyCopy(user.id)).rejects.toBeInstanceOf(BackupBusyError);
|
|
const after = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: first.id },
|
|
});
|
|
expect(after.chunkStreamId).toBe(before.chunkStreamId);
|
|
expect(await chunksOf(first.id)).toHaveLength(first.chunks);
|
|
await prisma.backupRestoreJob.update({
|
|
where: { id: job.id },
|
|
data: { status: "succeeded" },
|
|
});
|
|
}
|
|
// Once no restore reads it, the next run replaces it as usual.
|
|
const next = await weeklyCopy(user.id);
|
|
expect(next.id).toBe(first.id);
|
|
});
|
|
});
|
|
|
|
describe("a row that carries both forms", () => {
|
|
it("is refused with a reason, whichever form might be newer", async () => {
|
|
// How it happens: an older release that knows only the single value
|
|
// writes one into a row that already has pieces, after a downgrade.
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const { id } = await weeklyCopy(user.id);
|
|
await prisma.dataBackup.update({
|
|
where: { id },
|
|
data: {
|
|
data: await legacyStreamedBlobFrom(async (write) => {
|
|
await write("{}");
|
|
}),
|
|
},
|
|
});
|
|
const backup = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id },
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const { openStoredBackup } = await import("@/lib/export/stored-backup");
|
|
await expect(openStoredBackup(prisma, backup)).rejects.toThrow(
|
|
/both a single stored value and pieces/,
|
|
);
|
|
const res = await restoreRoute(id);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("is written in one form again by the next weekly run", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const { id } = await weeklyCopy(user.id);
|
|
await prisma.dataBackup.update({ where: { id }, data: { data: "x" } });
|
|
await weeklyCopy(user.id);
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({ where: { id } });
|
|
expect(row.data).toBeNull();
|
|
expect(await readStoredBackup(prisma, id)).toContain('"measurements"');
|
|
});
|
|
});
|
|
|
|
describe("two backups of the same account at the same time", () => {
|
|
it("run one after the other and leave one complete copy", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
await weeklyCopy(user.id);
|
|
const results = await Promise.allSettled([
|
|
weeklyCopy(user.id),
|
|
weeklyCopy(user.id),
|
|
]);
|
|
expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { userId: user.id, type: "WEEKLY_AUTO" },
|
|
});
|
|
expect(await chunksOf(row.id)).toHaveLength(row.chunkCount!);
|
|
expect(
|
|
JSON.parse(await readStoredBackup(prisma, row.id)).measurements,
|
|
).toHaveLength(COUNT);
|
|
});
|
|
});
|
|
|
|
describe("writing and removing copies", () => {
|
|
it("keeps the previous copy whole when a run fails after writing pieces", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const first = await weeklyCopy(user.id);
|
|
const before = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: first.id },
|
|
});
|
|
const text = await readStoredBackup(prisma, first.id);
|
|
|
|
let written = 0;
|
|
await expect(
|
|
storeBackupBlob(
|
|
prisma,
|
|
{ userId: user.id, type: "WEEKLY_AUTO" },
|
|
async (write) => {
|
|
await streamFullBackupJson(
|
|
prisma,
|
|
user.id,
|
|
async (chunk) => {
|
|
await write(chunk);
|
|
written += chunk.length;
|
|
if (written > 200 * 1024) throw new Error("read failed halfway");
|
|
},
|
|
{ purpose: "disaster-recovery" },
|
|
);
|
|
},
|
|
SMALL,
|
|
),
|
|
).rejects.toThrow("read failed halfway");
|
|
|
|
const after = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: first.id },
|
|
});
|
|
expect(after.chunkStreamId).toBe(before.chunkStreamId);
|
|
expect(after.chunkCount).toBe(before.chunkCount);
|
|
expect(after.createdAt).toEqual(before.createdAt);
|
|
expect(await chunksOf(first.id)).toHaveLength(first.chunks);
|
|
expect(await readStoredBackup(prisma, first.id)).toBe(text);
|
|
});
|
|
|
|
it("stops at the stored-copy limit and keeps the previous copy", async () => {
|
|
const user = await seedAccount();
|
|
const first = await weeklyCopy(user.id);
|
|
await expect(
|
|
weeklyCopy(user.id, { ...SMALL, maxBytes: 16 * 1024 }),
|
|
).rejects.toThrow(/BACKUP_MAX_STORED_MB/);
|
|
expect(await chunksOf(first.id)).toHaveLength(first.chunks);
|
|
});
|
|
|
|
it("replacing a copy leaves only the new copy's pieces", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
const first = await weeklyCopy(user.id);
|
|
const second = await weeklyCopy(user.id);
|
|
expect(second.id).toBe(first.id);
|
|
expect(await prisma.dataBackupChunk.count()).toBe(second.chunks);
|
|
});
|
|
|
|
it("wiping the account's data and deleting the account take the pieces with them", async () => {
|
|
const user = await seedAccount();
|
|
const prisma = getPrismaClient();
|
|
await weeklyCopy(user.id);
|
|
expect(await prisma.dataBackupChunk.count()).toBeGreaterThan(0);
|
|
// The wipe deletes `DataBackup` by owner; the pieces follow by cascade.
|
|
await prisma.dataBackup.deleteMany({ where: { userId: user.id } });
|
|
expect(await prisma.dataBackupChunk.count()).toBe(0);
|
|
|
|
await weeklyCopy(user.id);
|
|
await prisma.user.delete({ where: { id: user.id } });
|
|
expect(await prisma.dataBackupChunk.count()).toBe(0);
|
|
});
|
|
});
|