174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
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<string> {
|
|
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<string[]> {
|
|
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);
|
|
});
|
|
});
|