Files
SnapOtter/tests/unit/api/jobs/gdpr-export.behavior.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00

256 lines
8.3 KiB
TypeScript

import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
const selectMock = vi.hoisted(() => vi.fn());
const readStoredFileMock = vi.hoisted(() => vi.fn());
const putObjectMock = vi.hoisted(() => vi.fn());
function queryChain<T>(result: T) {
const chain = {
from: vi.fn(() => chain),
where: vi.fn(() => Promise.resolve(result)),
};
return chain;
}
async function loadGdprExport() {
vi.resetModules();
selectMock.mockReset();
readStoredFileMock.mockReset();
putObjectMock.mockReset();
vi.doMock("drizzle-orm", () => ({
eq: vi.fn(() => "eq"),
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
select: selectMock,
},
schema: {
users: { id: "users.id" },
userFiles: { userId: "userFiles.userId" },
jobs: { userId: "jobs.userId" },
auditLog: { actorId: "auditLog.actorId" },
},
}));
vi.doMock("../../../../apps/api/src/lib/file-storage.js", () => ({
readStoredFile: readStoredFileMock,
}));
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
putObject: putObjectMock,
}));
return import("../../../../apps/api/src/jobs/gdpr-export.js");
}
describe("GDPR export job behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("throws before writing output when the user does not exist", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock.mockReturnValueOnce(queryChain([]));
await expect(gdprExportJob("missing-user", "job-1")).rejects.toThrow(
"User missing-user not found",
);
expect(putObjectMock).not.toHaveBeenCalled();
});
it("writes a ZIP without passwordHash and skips missing library file contents", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock
.mockReturnValueOnce(
queryChain([
{
id: "user-1",
email: "ada@example.test",
passwordHash: "do-not-export",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
},
]),
)
.mockReturnValueOnce(
queryChain([
{
id: "file-1",
userId: "user-1",
storedName: "stored/a",
originalName: "a.txt",
createdAt: new Date("2026-06-02T00:00:00.000Z"),
},
{
id: "file-2",
userId: "user-1",
storedName: "stored/missing",
originalName: "missing.txt",
createdAt: new Date("2026-06-03T00:00:00.000Z"),
},
]),
)
.mockReturnValueOnce(
queryChain([
{
id: "job-a",
userId: "user-1",
createdAt: new Date("2026-06-04T00:00:00.000Z"),
startedAt: null,
completedAt: new Date("2026-06-04T00:01:00.000Z"),
deleteAfter: null,
},
]),
)
.mockReturnValueOnce(
queryChain([
{
id: "audit-1",
actorId: "user-1",
action: "LOGIN",
createdAt: new Date("2026-06-05T00:00:00.000Z"),
},
]),
);
readStoredFileMock
.mockResolvedValueOnce(Buffer.from("file contents"))
.mockRejectedValueOnce(new Error("missing"));
await expect(gdprExportJob("user-1", "export-job")).resolves.toEqual({
outputRef: "outputs/export-job/gdpr-export.zip",
});
expect(putObjectMock).toHaveBeenCalledTimes(1);
const [outputRef, zipBuffer] = putObjectMock.mock.calls[0];
expect(outputRef).toBe("outputs/export-job/gdpr-export.zip");
const zip = new AdmZip(zipBuffer);
const profile = JSON.parse(zip.readAsText("profile.json"));
const files = JSON.parse(zip.readAsText("files.json"));
const jobs = JSON.parse(zip.readAsText("jobs.json"));
const audit = JSON.parse(zip.readAsText("audit-log.json"));
expect(profile).toMatchObject({ id: "user-1", email: "ada@example.test" });
expect(profile).not.toHaveProperty("passwordHash");
expect(files[0].createdAt).toBe("2026-06-02T00:00:00.000Z");
expect(jobs[0].completedAt).toBe("2026-06-04T00:01:00.000Z");
expect(audit[0].createdAt).toBe("2026-06-05T00:00:00.000Z");
expect(zip.readAsText("library-files/file-1_a.txt")).toBe("file contents");
expect(zip.getEntry("library-files/file-2_missing.txt")).toBeNull();
});
it("serializes null/undefined date fields as null across every export section", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock
.mockReturnValueOnce(
queryChain([
{
id: "user-2",
email: "grace@example.test",
passwordHash: "secret",
createdAt: new Date("2026-06-10T00:00:00.000Z"),
},
]),
)
// File with a null createdAt exercises the falsy side of f.createdAt?.
.mockReturnValueOnce(
queryChain([
{
id: "file-9",
userId: "user-2",
storedName: "stored/x",
originalName: "x.bin",
createdAt: null,
},
]),
)
// Job where createdAt and completedAt are null (the two date branches the
// first test left truthy), startedAt/deleteAfter undefined.
.mockReturnValueOnce(
queryChain([
{
id: "job-b",
userId: "user-2",
createdAt: null,
startedAt: undefined,
completedAt: null,
deleteAfter: undefined,
},
]),
)
// Audit entry with a null createdAt exercises the falsy side of a.createdAt?.
.mockReturnValueOnce(
queryChain([
{
id: "audit-9",
actorId: "user-2",
action: "EXPORT",
createdAt: null,
},
]),
);
// Stored file read succeeds so the library-copy append path still runs.
readStoredFileMock.mockResolvedValueOnce(Buffer.from("bin"));
await expect(gdprExportJob("user-2", "export-null")).resolves.toEqual({
outputRef: "outputs/export-null/gdpr-export.zip",
});
expect(putObjectMock).toHaveBeenCalledTimes(1);
const zipBuffer = putObjectMock.mock.calls[0][1];
const zip = new AdmZip(zipBuffer);
const files = JSON.parse(zip.readAsText("files.json"));
const jobs = JSON.parse(zip.readAsText("jobs.json"));
const audit = JSON.parse(zip.readAsText("audit-log.json"));
// A null date passes through `?.toISOString()` as undefined, so JSON.stringify
// drops the key entirely rather than serializing it as null.
expect(files[0]).not.toHaveProperty("createdAt");
expect(jobs[0]).not.toHaveProperty("createdAt");
expect(jobs[0]).not.toHaveProperty("completedAt");
expect(jobs[0]).not.toHaveProperty("startedAt");
expect(jobs[0]).not.toHaveProperty("deleteAfter");
expect(audit[0]).not.toHaveProperty("createdAt");
expect(zip.readAsText("library-files/file-9_x.bin")).toBe("bin");
});
it("produces empty JSON collections when the user has no files, jobs, or audit entries", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock
.mockReturnValueOnce(
queryChain([
{
id: "user-3",
email: "empty@example.test",
passwordHash: "secret",
createdAt: new Date("2026-06-20T00:00:00.000Z"),
},
]),
)
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([]));
await expect(gdprExportJob("user-3", "export-empty")).resolves.toEqual({
outputRef: "outputs/export-empty/gdpr-export.zip",
});
// With no files, the library-copy loop never runs, so readStoredFile is untouched.
expect(readStoredFileMock).not.toHaveBeenCalled();
expect(putObjectMock).toHaveBeenCalledTimes(1);
const zipBuffer = putObjectMock.mock.calls[0][1];
const zip = new AdmZip(zipBuffer);
expect(JSON.parse(zip.readAsText("files.json"))).toEqual([]);
expect(JSON.parse(zip.readAsText("jobs.json"))).toEqual([]);
expect(JSON.parse(zip.readAsText("audit-log.json"))).toEqual([]);
const profile = JSON.parse(zip.readAsText("profile.json"));
expect(profile).toMatchObject({ id: "user-3", email: "empty@example.test" });
expect(profile).not.toHaveProperty("passwordHash");
});
});