fix(core): serialise attachment file mutations, atomic writes, reject svg

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-30 19:26:16 +01:00
1 parent 8f58ba4cdc
commit 4acb368c87
4 files changed
+157 -43

No files matched your search

+65
View File
@@ -105,4 +105,69 @@ forEachDialect("attachments", (getDb) => {
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(0);
expect(await db.k.selectFrom("attachments").selectAll().execute()).toHaveLength(0);
});
test("rejects svg and normalizes mime", async () => {
const db = getDb();
const u = await addUser(db, "u1");
const link = { entity: "work", entityId: "w1" } as const;
for (const mime of ["image/svg+xml", "IMAGE/SVG+XML; charset=utf-8"]) {
await expect(storeAttachment(db, dataDir, u, { ...png("x"), mime }, link)).rejects.toBeInstanceOf(
ValidationError,
);
}
const a = await storeAttachment(db, dataDir, u, { ...png("x"), mime: "Image/PNG; foo=bar" }, link);
expect(a.mime).toBe("image/png");
expect((await listAttachments(db, u, "work", "w1"))[0]?.mime).toBe("image/png");
});
test("shared bytes across users survive the other's unlink", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
const a = await storeAttachment(db, dataDir, ua, png("same"), { entity: "work", entityId: "w1" });
const b = await storeAttachment(db, dataDir, ub, png("same"), { entity: "work", entityId: "w1" });
await unlinkAttachment(db, dataDir, ua, a.id, "work", "w1");
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
const r = await readAttachment(db, dataDir, ub, b.id);
expect(await r?.file.text()).toBe("same");
});
test("another user's unlink is a no-op", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
const a = await storeAttachment(db, dataDir, ua, png("mine"), { entity: "work", entityId: "w1" });
await unlinkAttachment(db, dataDir, ub, a.id, "work", "w1");
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1);
});
test("listAttachments is scoped to the user", async () => {
const db = getDb();
const ua = await addUser(db, "a");
const ub = await addUser(db, "b");
await storeAttachment(db, dataDir, ua, png("one"), { entity: "work", entityId: "w1" });
expect(await listAttachments(db, ub, "work", "w1")).toHaveLength(0);
expect(await listAttachments(db, ua, "work", "w1")).toHaveLength(1);
});
test("concurrent store and unlink never leave a row without its file", async () => {
const db = getDb();
const u = await addUser(db, "u1");
const first = await storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w1" });
const [, second] = await Promise.all([
unlinkAttachment(db, dataDir, u, first.id, "work", "w1"),
storeAttachment(db, dataDir, u, png("race"), { entity: "work", entityId: "w2" }),
]);
const r = await readAttachment(db, dataDir, u, second.id);
expect(await r?.file.text()).toBe("race");
});
test("leaves no temp files behind", async () => {
const db = getDb();
const u = await addUser(db, "u1");
await storeAttachment(db, dataDir, u, png("clean"), { entity: "work", entityId: "w1" });
const files = await filesUnder(join(dataDir, "attachments"));
expect(files.some((f) => f.includes(".tmp-"))).toBe(false);
});
});
+13
View File
@@ -23,4 +23,17 @@ describe("crypto", () => {
await expect(decryptJson("k", "garbage")).rejects.toBeInstanceOf(CryptoError);
await expect(decryptJson("k", "v2.AA==.AA==")).rejects.toBeInstanceOf(CryptoError);
});
test("IV decodes to 12 bytes", async () => {
const blob = await encryptJson("k", { a: 1 });
expect(Buffer.from(blob.split(".")[1]!, "base64")).toHaveLength(12);
});
test("flipping a ciphertext byte throws CryptoError", async () => {
const [v, iv, ct] = (await encryptJson("k", { a: 1 })).split(".");
const bytes = Buffer.from(ct!, "base64");
bytes[0] = bytes[0]! ^ 0xff;
const tampered = `${v}.${iv}.${bytes.toString("base64")}`;
await expect(decryptJson("k", tampered)).rejects.toBeInstanceOf(CryptoError);
});
});