import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { newId, nowIso } from "@sttracker/db/ids"; import { forEachDialect } from "@sttracker/db/testing"; import type { Db } from "@sttracker/db/db"; import { listAttachments, readAttachment, storeAttachment, unlinkAttachment, } from "../src/attachments.ts"; import { ValidationError } from "../src/errors.ts"; async function addUser(db: Db, username: string): Promise { const id = newId(); const now = nowIso(); await db.k .insertInto("users") .values({ id, username, display_name: username, password_hash: "x", role: "member", preferences: "{}", created_at: now, updated_at: now, }) .execute(); return id; } async function filesUnder(dir: string): Promise { const all = await readdir(dir, { recursive: true, withFileTypes: true }); return all.filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)); } forEachDialect("attachments", (getDb) => { let dataDir: string; beforeEach(async () => { dataDir = await mkdtemp(join(tmpdir(), "sttracker-att-")); }); afterEach(async () => { await rm(dataDir, { recursive: true, force: true }); }); const png = (s: string) => ({ bytes: new TextEncoder().encode(s), mime: "image/png", filename: "a.png", }); test("same bytes twice: one file, two links", async () => { const db = getDb(); const u = await addUser(db, "u1"); const a = await storeAttachment(db, dataDir, u, png("hello"), { entity: "work", entityId: "w1" }); const b = await storeAttachment(db, dataDir, u, png("hello"), { entity: "entry", entityId: "e1" }); expect(a.sha256).toBe(b.sha256); expect(a.id).not.toBe(b.id); const files = await filesUnder(join(dataDir, "attachments")); expect(files).toHaveLength(1); expect(files[0]).toBe( join(dataDir, "attachments", a.sha256.slice(0, 2), a.sha256.slice(2, 4), a.sha256), ); const links = await db.k.selectFrom("attachment_links").selectAll().execute(); expect(links).toHaveLength(2); expect(await listAttachments(db, u, "work", "w1")).toHaveLength(1); }); test("rejects disallowed mime and oversize", async () => { const db = getDb(); const u = await addUser(db, "u1"); const link = { entity: "work", entityId: "w1" } as const; await expect( storeAttachment(db, dataDir, u, { ...png("x"), mime: "application/x-msdownload", filename: "a.exe" }, link), ).rejects.toBeInstanceOf(ValidationError); await expect( storeAttachment(db, dataDir, u, { ...png("x"), bytes: new Uint8Array(25 * 1024 * 1024 + 1) }, link), ).rejects.toBeInstanceOf(ValidationError); }); test("reads own file; another user gets null", async () => { const db = getDb(); const u1 = await addUser(db, "u1"); const u2 = await addUser(db, "u2"); const a = await storeAttachment(db, dataDir, u1, png("secret"), { entity: "work", entityId: "w1" }); const r = await readAttachment(db, dataDir, u1, a.id); expect(r?.mime).toBe("image/png"); expect(await r?.file.text()).toBe("secret"); expect(await readAttachment(db, dataDir, u2, a.id)).toBeNull(); expect(await readAttachment(db, dataDir, u1, "nope")).toBeNull(); }); test("unlink removes link, orphan row and unshared file", async () => { const db = getDb(); const u = await addUser(db, "u1"); const a = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w1" }); const b = await storeAttachment(db, dataDir, u, png("bye"), { entity: "work", entityId: "w2" }); await unlinkAttachment(db, dataDir, u, a.id, "work", "w1"); expect(await listAttachments(db, u, "work", "w1")).toHaveLength(0); expect(await filesUnder(join(dataDir, "attachments"))).toHaveLength(1); await unlinkAttachment(db, dataDir, u, b.id, "work", "w2"); 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); }); });