server/attachments.test.ts
Rama main · réplica de lectura del repositorio original.
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
truncateSync,
unlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { link, unlink } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:fs/promises", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs/promises")>();
return { ...fs, link: vi.fn(fs.link), unlink: vi.fn(fs.unlink) };
});
vi.mock("node:fs", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs")>();
return { ...fs, unlinkSync: vi.fn(fs.unlinkSync), writeFileSync: vi.fn(fs.writeFileSync) };
});
const realUnlink = (await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises")).unlink;
const realLink = (await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises")).link;
const realUnlinkSync = (await vi.importActual<typeof import("node:fs")>("node:fs")).unlinkSync;
const realWriteFileSync = (await vi.importActual<typeof import("node:fs")>("node:fs")).writeFileSync;
afterEach(() => {
vi.mocked(unlink).mockReset().mockImplementation(realUnlink);
vi.mocked(link).mockReset().mockImplementation(realLink);
vi.mocked(unlinkSync).mockReset().mockImplementation(realUnlinkSync);
vi.mocked(writeFileSync).mockReset().mockImplementation(realWriteFileSync);
});
// The module reads DATA_DIR at import time, so the env var must be set
// before the import is evaluated.
const DATA_ROOT = mkdtempSync(join(tmpdir(), "omb-attachments-"));
process.env.OMB_DATA_DIR = join(DATA_ROOT, "data");
const {
ATTACHMENTS_DIR,
ATTACHMENTS_MAX_BYTES,
ATTACHMENT_CLEAR_MIN_AGE_MS,
ATTACHMENT_PARTIAL_MAX_AGE_MS,
DEFAULT_ATTACHMENTS_MAX_BYTES,
FILE_MAX_BYTES,
IMAGE_MAX_BYTES,
PREVIEW_MAX_BYTES,
__resetAttachmentAccountingForTests,
attachmentStorageUsage,
attachmentsMaxBytes,
clearAttachments,
keptAttachmentNames,
offerAttachmentClear,
cleanupStaleAttachmentPartials,
deleteAttachment,
extensionForFileMime,
extensionForMime,
parseAudioRange,
readAttachment,
sanitizeSharedFileName,
saveAudio,
saveFile,
saveImage,
saveImageUpload,
validateAttachmentUploadId,
} = await import("./attachments.ts");
// Anything that mutates ATTACHMENTS_DIR on disk must reset the in-memory quota cache, or the next check trusts stale counts.
function resetDir() {
rmSync(ATTACHMENTS_DIR, { recursive: true, force: true });
__resetAttachmentAccountingForTests();
}
const UPLOAD_A = "11111111-1111-4111-8111-111111111111";
const UPLOAD_B = "22222222-2222-4222-8222-222222222222";
describe("extensionForMime", () => {
it("maps the accepted image mimes to extensions", () => {
expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/jpeg")).toBe(".jpg");
expect(extensionForMime("image/gif")).toBe(".gif");
expect(extensionForMime("image/webp")).toBe(".webp");
});
it("tolerates parameters and casing", () => {
expect(extensionForMime("Image/PNG; charset=binary")).toBe(".png");
expect(extensionForMime(" image/webp ")).toBe(".webp");
});
it("refuses everything else — including svg, which executes script", () => {
expect(extensionForMime("image/svg+xml")).toBeNull();
expect(extensionForMime("text/plain")).toBeNull();
expect(extensionForMime(undefined)).toBeNull();
});
});
describe("saveImage", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
it("persists bytes under the attachments dir with a generated name", () => {
const saved = saveImage(Buffer.from("png-bytes"), "image/png");
expect(saved.path.startsWith(ATTACHMENTS_DIR)).toBe(true);
expect(saved.path.endsWith(".png")).toBe(true);
expect(saved.bytes).toBe(9);
expect(saved.mime).toBe("image/png");
if (process.platform !== "win32") {
expect(statSync(ATTACHMENTS_DIR).mode & 0o777).toBe(0o700);
expect(statSync(saved.path).mode & 0o777).toBe(0o600);
}
});
it("round-trips through readAttachment with the right mime", () => {
const saved = saveImage(Buffer.from("gif!"), "image/gif");
const name = saved.path.split(/[\\/]/).pop()!;
const back = readAttachment(name);
expect(back?.bytes.toString()).toBe("gif!");
expect(back?.mime).toBe("image/gif");
});
it("rejects unsupported mimes, empty bodies, and oversize bodies", () => {
expect(() => saveImage(Buffer.from("x"), "image/svg+xml")).toThrow(/unsupported image type/);
expect(() => saveImage(Buffer.alloc(0), "image/png")).toThrow(/empty/);
expect(() => saveImage(Buffer.alloc(IMAGE_MAX_BYTES + 1), "image/png")).toThrow(/exceeds/);
});
it("makes UUID-keyed image retries idempotent without changing legacy callers", () => {
const first = saveImage(Buffer.from("same"), "image/png", UPLOAD_A);
const retry = saveImage(Buffer.from("same"), "image/png", UPLOAD_A.toUpperCase());
expect(retry).toEqual(first);
expect(readdirSync(ATTACHMENTS_DIR).filter((name) => !name.startsWith("."))).toEqual([`${UPLOAD_A}.png`]);
expect(() => saveImage(Buffer.from("different"), "image/png", UPLOAD_A)).toThrow(/different image bytes/);
expect(() => saveImage(Buffer.from("same"), "image/jpeg", UPLOAD_A)).toThrow(/another content type/);
const legacyOne = saveImage(Buffer.from("same"), "image/png");
const legacyTwo = saveImage(Buffer.from("same"), "image/png");
expect(legacyOne.path).not.toBe(legacyTwo.path);
});
it("validates upload IDs before they can become filenames", () => {
expect(validateAttachmentUploadId(UPLOAD_A.toUpperCase())).toBe(UPLOAD_A);
for (const value of ["", "short", "../../escape", `${UPLOAD_A}.png`, "00000000-0000-0000-0000-000000000000"]) {
expect(() => validateAttachmentUploadId(value)).toThrow(/UUID/);
}
});
});
describe("saveAudio", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
it("persists an mp3 under the attachments dir with a generated name", () => {
const saved = saveAudio(Buffer.from("mp3-bytes"), "audio/mpeg");
expect(saved.path.startsWith(ATTACHMENTS_DIR)).toBe(true);
expect(saved.path.endsWith(".mp3")).toBe(true);
expect(saved.bytes).toBe(9);
expect(saved.mime).toBe("audio/mpeg");
if (process.platform !== "win32") expect(statSync(saved.path).mode & 0o777).toBe(0o600);
expect(readFileSync(saved.path).toString()).toBe("mp3-bytes");
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([saved.path.split(/[\\/]/).pop()!]);
});
it("serves a saved note back through readAttachment as audio/mpeg", () => {
const saved = saveAudio(Buffer.from("mp3-note!"), "audio/mpeg");
const name = saved.path.split(/[\\/]/).pop()!;
const back = readAttachment(name);
expect(back?.bytes.toString()).toBe("mp3-note!");
expect(back?.mime).toBe("audio/mpeg");
});
it("normalizes mime parameters and casing", () => {
const saved = saveAudio(Buffer.from("x"), "Audio/MPEG; charset=binary");
expect(saved.mime).toBe("audio/mpeg");
expect(saved.path.endsWith(".mp3")).toBe(true);
});
it("keeps a WAV note as .wav and serves it back as audio/wav", () => {
for (const mime of ["audio/wav", "audio/x-wav", "Audio/Wave"]) {
const saved = saveAudio(Buffer.from(`RIFF-${mime}`), mime);
expect(saved.mime).toBe("audio/wav");
expect(saved.path.endsWith(".wav")).toBe(true);
const back = readAttachment(saved.path.split(/[\\/]/).pop()!);
expect(back?.bytes.toString()).toBe(`RIFF-${mime}`);
expect(back?.mime).toBe("audio/wav");
}
});
it("rejects other audio mimes, empty bodies, and oversize bodies", () => {
expect(() => saveAudio(Buffer.from("x"), "audio/ogg")).toThrow(/unsupported audio type/);
expect(() => saveAudio(Buffer.alloc(0), "audio/mpeg")).toThrow(/empty/);
expect(() => saveAudio(Buffer.alloc(PREVIEW_MAX_BYTES + 1), "audio/mpeg")).toThrow(/exceeds/);
});
it("rejects at the aggregate ceiling without leaving partials and releases its reservation", () => {
const referenced = saveImage(Buffer.from("x"), "image/png");
truncateSync(referenced.path, ATTACHMENTS_MAX_BYTES);
__resetAttachmentAccountingForTests();
try {
saveAudio(Buffer.from("y"), "audio/mpeg");
throw new Error("expected quota rejection");
} catch (error) {
expect(error).toMatchObject({ status: 507 });
expect(error).toHaveProperty("message", expect.stringMatching(/storage is full/));
}
expect(readdirSync(ATTACHMENTS_DIR).every((name) => !name.endsWith(".partial"))).toBe(true);
truncateSync(referenced.path, 4);
__resetAttachmentAccountingForTests();
const note = saveAudio(Buffer.from("note"), "audio/mpeg");
expect(statSync(note.path).size).toBe(4);
});
it("cleans up its partial and reservation when the write fails", () => {
vi.mocked(writeFileSync).mockImplementationOnce(() => {
throw new Error("disk full");
});
expect(() => saveAudio(Buffer.from("note"), "audio/mpeg")).toThrow(/disk full/);
expect(readdirSync(ATTACHMENTS_DIR).every((name) => !name.endsWith(".partial"))).toBe(true);
expect(() => saveAudio(Buffer.from("note"), "audio/mpeg")).not.toThrow();
});
});
describe("attachmentsMaxBytes", () => {
it("keeps 512 MB unless OMB_ATTACHMENTS_MAX_BYTES is a positive whole number of bytes", () => {
expect(ATTACHMENTS_MAX_BYTES).toBe(DEFAULT_ATTACHMENTS_MAX_BYTES);
expect(attachmentsMaxBytes({})).toBe(512 * 1024 * 1024);
expect(attachmentsMaxBytes({ OMB_ATTACHMENTS_MAX_BYTES: " 4294967296 " })).toBe(4 * 1024 * 1024 * 1024);
for (const bad of ["", "0", "-1", "2GB", "1.5", "NaN"]) {
expect(attachmentsMaxBytes({ OMB_ATTACHMENTS_MAX_BYTES: bad })).toBe(DEFAULT_ATTACHMENTS_MAX_BYTES);
}
});
});
describe("aggregate attachment storage", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
it("rejects new data at the aggregate ceiling without pruning committed attachments", () => {
const referenced = saveImage(Buffer.from("x"), "image/png");
truncateSync(referenced.path, ATTACHMENTS_MAX_BYTES);
__resetAttachmentAccountingForTests();
try {
saveImage(Buffer.from("y"), "image/png");
throw new Error("expected quota rejection");
} catch (error) {
expect(error).toMatchObject({ status: 507 });
expect(error).toHaveProperty("message", "attachments storage is full (512 MB limit). Clear old attachments in Settings → General.");
}
expect(existsSync(referenced.path)).toBe(true);
expect(statSync(referenced.path).size).toBe(ATTACHMENTS_MAX_BYTES);
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([referenced.path.split(/[\\/]/).pop()!]);
});
it("counts concurrent reservations so uploads cannot race past the ceiling", async () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 5);
__resetAttachmentAccountingForTests();
const first = saveFile((async function* () {
yield Buffer.from("four");
})(), "first.txt", "text/plain", { expectedBytes: 4 });
expect(() => saveImage(Buffer.from("xx"), "image/png")).toThrow(/storage is full/);
await expect(first).resolves.toMatchObject({ bytes: 4 });
});
it("lets an unknown-length stream use the exact space left below a reservation increment", async () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 2);
__resetAttachmentAccountingForTests();
const saved = await saveFile((async function* () {
yield Buffer.from("a");
})(), "last-byte.txt", "text/plain");
expect(saved.bytes).toBe(1);
expect(() => saveImage(Buffer.from("b"), "image/png")).not.toThrow();
});
it("releases reservations and removes partials after failed uploads", async () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 3);
__resetAttachmentAccountingForTests();
await expect(saveFile((async function* () {})(), "empty.txt", "text/plain", { expectedBytes: 3 }))
.rejects.toThrow(/empty file/);
expect(() => saveImage(Buffer.from("123"), "image/png")).not.toThrow();
expect(readdirSync(ATTACHMENTS_DIR).every((name) => !name.endsWith(".partial"))).toBe(true);
});
it("cleans only stale upload partials, never committed or active-looking files", () => {
saveImage(Buffer.from("kept"), "image/png", UPLOAD_A);
const stale = `${ATTACHMENTS_DIR}/.openmaus-upload-${UPLOAD_A}-${UPLOAD_B}.partial`;
const fresh = `${ATTACHMENTS_DIR}/.openmaus-upload-${UPLOAD_B}-${UPLOAD_A}.partial`;
const unrelated = `${ATTACHMENTS_DIR}/notes.partial`;
writeFileSync(stale, "stale");
writeFileSync(fresh, "fresh");
writeFileSync(unrelated, "not ours");
const now = Date.now();
const old = new Date(now - ATTACHMENT_PARTIAL_MAX_AGE_MS - 1_000);
utimesSync(stale, old, old);
expect(cleanupStaleAttachmentPartials(now)).toBe(1);
expect(existsSync(stale)).toBe(false);
expect(existsSync(fresh)).toBe(true);
expect(existsSync(unrelated)).toBe(true);
expect(existsSync(`${ATTACHMENTS_DIR}/${UPLOAD_A}.png`)).toBe(true);
});
it("counts fresh crash leftovers against quota, then reclaims them once a cleanup sweep runs", () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 2);
__resetAttachmentAccountingForTests();
const orphan = `${ATTACHMENTS_DIR}/.openmaus-upload-${UPLOAD_A}-${UPLOAD_B}.partial`;
writeFileSync(orphan, "xx");
expect(() => saveImage(Buffer.from("y"), "image/png")).toThrow(/storage is full/);
const old = new Date(Date.now() - ATTACHMENT_PARTIAL_MAX_AGE_MS - 1_000);
utimesSync(orphan, old, old);
expect(cleanupStaleAttachmentPartials()).toBe(1);
expect(() => saveImage(Buffer.from("y"), "image/png")).not.toThrow();
expect(existsSync(orphan)).toBe(false);
});
it("reclaims an inactive partial immediately when its upload ID retries", async () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 3);
__resetAttachmentAccountingForTests();
const orphan = `${ATTACHMENTS_DIR}/.openmaus-upload-${UPLOAD_A}-${UPLOAD_B}.partial`;
writeFileSync(orphan, "old");
const saved = await saveFile((async function* () {
yield Buffer.from("new");
})(), "retry.txt", "text/plain", { uploadId: UPLOAD_A, expectedBytes: 3 });
expect(saved.path.endsWith(`${UPLOAD_A}.txt`)).toBe(true);
expect(existsSync(orphan)).toBe(false);
});
it("frees quota after deleteAttachment, without a rescan, for a file the cache already knows about", () => {
const first = saveImage(Buffer.from("x"), "image/png");
truncateSync(first.path, ATTACHMENTS_MAX_BYTES - 2);
__resetAttachmentAccountingForTests();
expect(() => saveImage(Buffer.from("yyy"), "image/png")).toThrow(/storage is full/);
deleteAttachment(first.path);
expect(existsSync(first.path)).toBe(false);
const second = saveImage(Buffer.from("yyy"), "image/png");
expect(second.bytes).toBe(3);
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([second.path.split(/[\\/]/).pop()!]);
});
it("frees quota for a file deleted outside the app once the store looks full", () => {
const first = saveImage(Buffer.from("x"), "image/png");
truncateSync(first.path, ATTACHMENTS_MAX_BYTES - 2);
__resetAttachmentAccountingForTests();
expect(() => saveImage(Buffer.from("yyy"), "image/png")).toThrow(/storage is full/);
realUnlinkSync(first.path);
expect(saveImage(Buffer.from("yyy"), "image/png").bytes).toBe(3);
});
it.each([1, 2])("counts a committed upload after %i partial-cleanup failures and an idempotent retry", async (failures) => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 3);
__resetAttachmentAccountingForTests();
const cleanupError = Object.assign(new Error("partial file is locked"), { code: "EPERM" });
for (let attempt = 0; attempt < failures; attempt++) {
vi.mocked(unlink).mockRejectedValueOnce(cleanupError);
}
const chunks = async function* () { yield Buffer.from("xx"); };
await expect(saveFile(chunks(), "retry.txt", "text/plain", { uploadId: UPLOAD_A }))
.rejects.toThrow("partial file is locked");
expect(readFileSync(join(ATTACHMENTS_DIR, `${UPLOAD_A}.txt`), "utf8")).toBe("xx");
expect(readdirSync(ATTACHMENTS_DIR).filter((name) => name.endsWith(".partial"))).toHaveLength(failures - 1);
await expect(saveFile(chunks(), "retry.txt", "text/plain", { uploadId: UPLOAD_A }))
.resolves.toMatchObject({ bytes: 2 });
expect(readdirSync(ATTACHMENTS_DIR).some((name) => name.endsWith(".partial"))).toBe(false);
expect(() => saveImage(Buffer.from("y"), "image/png")).not.toThrow();
expect(() => saveImage(Buffer.from("z"), "image/png")).toThrow(/storage is full/);
});
it.each([1, 2])("counts an image after %i partial-cleanup failures and an idempotent retry", async (failures) => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 3);
__resetAttachmentAccountingForTests();
for (let attempt = 0; attempt < failures; attempt++) {
vi.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error("partial file is locked"), { code: "EPERM" });
});
}
expect(() => saveImage(Buffer.from("xx"), "image/png", UPLOAD_A)).toThrow("partial file is locked");
expect(readFileSync(join(ATTACHMENTS_DIR, `${UPLOAD_A}.png`), "utf8")).toBe("xx");
expect(readdirSync(ATTACHMENTS_DIR).filter((name) => name.endsWith(".partial"))).toHaveLength(failures - 1);
await expect(saveImageUpload(Buffer.from("xx"), "image/png", UPLOAD_A)).resolves.toMatchObject({ bytes: 2 });
expect(readdirSync(ATTACHMENTS_DIR).some((name) => name.endsWith(".partial"))).toBe(false);
expect(() => saveImage(Buffer.from("y"), "image/png")).not.toThrow();
expect(() => saveImage(Buffer.from("z"), "image/png")).toThrow(/storage is full/);
});
it("does not count an in-flight commit twice when cleanup failure causes a rescan", async () => {
const existing = saveImage(Buffer.from("x"), "image/png");
truncateSync(existing.path, ATTACHMENTS_MAX_BYTES - 5);
__resetAttachmentAccountingForTests();
let markLinked!: () => void;
let finishCommit!: () => void;
const linked = new Promise<void>((resolve) => { markLinked = resolve; });
const finishing = new Promise<void>((resolve) => { finishCommit = resolve; });
vi.mocked(link).mockImplementationOnce(async (...args) => {
await realLink(...args);
markLinked();
await finishing;
});
const pending = saveFile((async function* () { yield Buffer.from("xx"); })(), "pending.txt", "text/plain", {
uploadId: UPLOAD_A, expectedBytes: 2,
});
try {
await linked;
for (let attempt = 0; attempt < 2; attempt++) {
vi.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error("partial file is locked"), { code: "EPERM" });
});
}
expect(() => saveImage(Buffer.from("x"), "image/png", UPLOAD_B)).toThrow("partial file is locked");
// This scan sees the linked file while its commit callback is pending.
expect(() => saveImage(Buffer.from("y"), "image/png")).toThrow(/storage is full/);
} finally {
finishCommit();
await pending;
}
await saveImageUpload(Buffer.from("x"), "image/png", UPLOAD_B);
expect(() => saveImage(Buffer.from("yy"), "image/png")).not.toThrow();
expect(() => saveImage(Buffer.from("z"), "image/png")).toThrow(/storage is full/);
});
it("initializes correctly on a fresh process against a directory that already has files", () => {
// No save has happened yet in this test, so the cache is cold, as after a restart that finds attachments on disk.
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const preexisting = join(ATTACHMENTS_DIR, "11111111-1111-4111-8111-111111111111.png");
writeFileSync(preexisting, "x");
truncateSync(preexisting, ATTACHMENTS_MAX_BYTES - 2);
expect(() => saveImage(Buffer.from("yyy"), "image/png")).toThrow(/storage is full/);
expect(() => saveImage(Buffer.from("y"), "image/png")).not.toThrow();
});
});
describe("readAttachment name lock", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
it("refuses traversal, dotfiles, and names the saver never writes", () => {
expect(readAttachment("..%2F..%2Fconfig.json")).toBeNull();
expect(readAttachment(".env")).toBeNull();
expect(readAttachment("a/b.png")).toBeNull();
expect(readAttachment("no-extension")).toBeNull();
expect(readAttachment("uuid.jpeg")).toBeNull(); // saved as .jpg
expect(readAttachment("note.wav")).toBeNull(); // only .mp3 audio is written
});
});
describe("parseAudioRange", () => {
const SIZE = 1000;
it("parses bounded, open-ended, and suffix ranges", () => {
expect(parseAudioRange("bytes=0-499", SIZE)).toEqual({ kind: "range", start: 0, end: 499 });
expect(parseAudioRange("bytes=500-999", SIZE)).toEqual({ kind: "range", start: 500, end: 999 });
expect(parseAudioRange("bytes=400-", SIZE)).toEqual({ kind: "range", start: 400, end: 999 });
expect(parseAudioRange("bytes=-100", SIZE)).toEqual({ kind: "range", start: 900, end: 999 });
// a suffix longer than the file is the whole file
expect(parseAudioRange("bytes=-9999", SIZE)).toEqual({ kind: "range", start: 0, end: 999 });
});
it("clamps an end past EOF to the last byte", () => {
expect(parseAudioRange("bytes=990-5000", SIZE)).toEqual({ kind: "range", start: 990, end: 999 });
});
it("answers unsatisfiable for a start at or past EOF and a zero suffix", () => {
expect(parseAudioRange("bytes=1000-", SIZE)).toEqual({ kind: "unsatisfiable" });
expect(parseAudioRange("bytes=1000-1200", SIZE)).toEqual({ kind: "unsatisfiable" });
expect(parseAudioRange("bytes=-0", SIZE)).toEqual({ kind: "unsatisfiable" });
expect(parseAudioRange("bytes=0-", 0)).toEqual({ kind: "unsatisfiable" });
});
it("ignores absent, malformed, multi-range, and non-bytes headers", () => {
expect(parseAudioRange(undefined, SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("bytes=-", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("bytes=0-99,200-299", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("bytes=a-b", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("bytes=99-0", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("bytes=0 - 99", SIZE)).toEqual({ kind: "none" });
expect(parseAudioRange("chunks=0-99", SIZE)).toEqual({ kind: "none" });
// surrounding OWS is legal per RFC 9110 and common from proxies
expect(parseAudioRange(" bytes=0-99 ", SIZE)).toEqual({ kind: "range", start: 0, end: 99 });
});
it("distinguishes an empty suffix from a zero suffix", () => {
// no bounds at all is malformed input: answer with the full 200 body
expect(parseAudioRange("bytes=-", SIZE)).toEqual({ kind: "none" });
// a real zero-length suffix is valid but unsatisfiable, per RFC 9110
expect(parseAudioRange("bytes=-0", SIZE)).toEqual({ kind: "unsatisfiable" });
});
});
describe("shared files", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
it("allows useful document mimes but not executables, archives, or active markup", () => {
expect(extensionForFileMime("text/plain; charset=utf-8")).toBe(".txt");
expect(extensionForFileMime("application/pdf")).toBe(".pdf");
expect(extensionForFileMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe(".docx");
expect(extensionForFileMime("application/zip")).toBeNull();
expect(extensionForFileMime("application/x-msdownload")).toBeNull();
expect(extensionForFileMime("application/octet-stream")).toBeNull();
expect(extensionForFileMime("text/html")).toBeNull();
expect(extensionForFileMime("image/svg+xml")).toBeNull();
});
it("sanitizes the display name and derives its extension from the mime", () => {
expect(sanitizeSharedFileName(" Quarterly: report.exe ", "application/pdf")).toBe("Quarterly_ report.pdf");
expect(sanitizeSharedFileName("notes", "text/markdown")).toBe("notes.md");
expect(() => sanitizeSharedFileName("../../secret.txt", "text/plain")).toThrow(/filename, not a path/);
expect(() => sanitizeSharedFileName("..\\..\\secret.txt", "text/plain")).toThrow(/filename, not a path/);
expect(() => sanitizeSharedFileName("..%2F..%2Fsecret.txt", "text/plain")).toThrow(/filename, not a path/);
expect(() => sanitizeSharedFileName("notes.txt", "application/zip")).toThrow(/supported document/);
});
it("streams a file under a generated name with private permissions", async () => {
async function* chunks() {
yield Buffer.from("first ");
yield Buffer.from("second");
}
const saved = await saveFile(chunks(), "Meeting notes.md", "Text/Markdown; charset=utf-8");
expect(saved.name).toBe("Meeting notes.md");
expect(saved.mime).toBe("text/markdown");
expect(saved.bytes).toBe(12);
expect(saved.path.startsWith(ATTACHMENTS_DIR)).toBe(true);
expect(saved.path).toMatch(/[0-9a-f-]+\.md$/);
expect(readFileSync(saved.path, "utf8")).toBe("first second");
if (process.platform !== "win32") {
expect(statSync(ATTACHMENTS_DIR).mode & 0o777).toBe(0o700);
expect(statSync(saved.path).mode & 0o777).toBe(0o600);
}
});
it.each([
["audio/opus", ".opus"], ["audio/ogg; codecs=opus", ".ogg"],
["audio/mpeg", ".mp3"], ["audio/mp4", ".m4a"],
["audio/x-wav", ".wav"], ["audio/aac", ".aac"],
["audio/flac", ".flac"], ["audio/webm", ".webm"],
])("stores %s audio privately and retries without duplicating it", async (mime, extension) => {
const bytes = Buffer.from([0, 255, 1, 128, 79, 103, 103, 83]);
const chunks = async function* () { yield bytes.subarray(0, 3); yield bytes.subarray(3); };
const first = await saveFile(chunks(), "Voice note.opus", mime, { uploadId: UPLOAD_A });
const again = await saveFile(chunks(), "Voice note.opus", mime, { uploadId: UPLOAD_A });
expect(again.path).toBe(first.path);
expect(first.path).toBe(join(ATTACHMENTS_DIR, `${UPLOAD_A}${extension}`));
expect(first.name).toBe(`Voice note${extension}`);
expect(readFileSync(first.path)).toEqual(bytes);
if (process.platform !== "win32") expect(statSync(first.path).mode & 0o777).toBe(0o600);
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([`${UPLOAD_A}${extension}`]);
});
it("rejects empty and oversized streams without leaving partial files", async () => {
await expect(saveFile((async function* () {})(), "empty.txt", "text/plain")).rejects.toThrow(/empty file/);
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([]);
async function* tooLarge() {
yield Buffer.from("partial");
yield Buffer.alloc(FILE_MAX_BYTES);
}
await expect(saveFile(tooLarge(), "large.pdf", "application/pdf")).rejects.toMatchObject({ status: 413 });
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([]);
});
it("keeps a file larger than a preview: only what is read whole stays at the preview size", async () => {
const bytes = PREVIEW_MAX_BYTES + 1;
async function* reel() {
yield Buffer.alloc(PREVIEW_MAX_BYTES);
yield Buffer.from("x");
}
const saved = await saveFile(reel(), "reel.mp4", "video/mp4", { expectedBytes: bytes });
expect(saved).toMatchObject({ name: "reel.mp4", mime: "video/mp4", bytes });
expect(statSync(saved.path).size).toBe(bytes);
expect(FILE_MAX_BYTES).toBeGreaterThan(PREVIEW_MAX_BYTES);
});
it("deduplicates concurrent and later file retries by upload ID", async () => {
const makeChunks = async function* () {
yield Buffer.from("same ");
await Promise.resolve();
yield Buffer.from("document");
};
const [first, concurrentRetry] = await Promise.all([
saveFile(makeChunks(), "Report.pdf", "application/pdf", { uploadId: UPLOAD_A, expectedBytes: 13 }),
saveFile(makeChunks(), "Report.pdf", "application/pdf", { uploadId: UPLOAD_A, expectedBytes: 13 }),
]);
expect(concurrentRetry.path).toBe(first.path);
expect(readFileSync(first.path, "utf8")).toBe("same document");
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([`${UPLOAD_A}.pdf`]);
await expect(saveFile((async function* () {
yield Buffer.from("other bytes");
})(), "Report.pdf", "application/pdf", { uploadId: UPLOAD_A }))
.rejects.toMatchObject({ status: 409 });
expect(readFileSync(first.path, "utf8")).toBe("same document");
});
it("serializes the upload ID namespace across image and document routes", async () => {
let started!: () => void;
let finish!: () => void;
const didStart = new Promise<void>((resolve) => { started = resolve; });
const mayFinish = new Promise<void>((resolve) => { finish = resolve; });
const document = saveFile((async function* () {
yield Buffer.from("first");
started();
await mayFinish;
yield Buffer.from("second");
})(), "race.pdf", "application/pdf", { uploadId: UPLOAD_A, expectedBytes: 11 });
await didStart;
const image = saveImageUpload(Buffer.from("image"), "image/png", UPLOAD_A);
finish();
await expect(document).resolves.toMatchObject({ bytes: 11 });
await expect(image).rejects.toMatchObject({ status: 409 });
expect(readdirSync(ATTACHMENTS_DIR)).toEqual([`${UPLOAD_A}.pdf`]);
});
});
describe("clearing attachments from Settings", () => {
beforeEach(() => {
resetDir();
});
afterEach(() => {
resetDir();
});
const nameOf = (path: string) => path.split(/[\\/]/).pop()!;
it("keeps pictures by their url and files by their path, on any platform", () => {
const kept = keptAttachmentNames(
["/api/attachments/bot.png", null, undefined, "https://example.com/face.png"],
["banner.webp", "C:\\Users\\me\\data\\attachments\\routine.pdf", "/home/me/data/attachments/run.png", undefined],
);
expect([...kept].sort()).toEqual(["banner.webp", "bot.png", "routine.pdf", "run.png"]);
});
const age = (path: string, ms: number) => {
const at = new Date(Date.now() - ms);
utimesSync(path, at, at);
};
it("frees a full store so the next upload works, keeping pictures, routine files and fresh uploads", () => {
const old = saveImage(Buffer.from("old"), "image/png");
const picture = saveImage(Buffer.from("avatar"), "image/png");
const fresh = saveImage(Buffer.from("fresh"), "image/png");
truncateSync(old.path, ATTACHMENTS_MAX_BYTES - 64);
age(old.path, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
age(picture.path, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
__resetAttachmentAccountingForTests();
expect(() => saveImage(Buffer.from("x".repeat(100)), "image/png")).toThrow(/storage is full.*Settings/);
const cleared = clearAttachments(new Set([nameOf(picture.path)]));
expect(cleared).toEqual({ removed: 1, freedBytes: ATTACHMENTS_MAX_BYTES - 64, failed: 0 });
expect(existsSync(old.path)).toBe(false);
expect(existsSync(picture.path)).toBe(true);
expect(existsSync(fresh.path)).toBe(true);
expect(attachmentStorageUsage()).toEqual({ usedBytes: "avatar".length + "fresh".length, maxBytes: ATTACHMENTS_MAX_BYTES });
expect(() => saveImage(Buffer.from("x".repeat(100)), "image/png")).not.toThrow();
});
it("removes a deleted or cleared upload from every conversation's box uploads folder too, so no box can still read it", () => {
const deleted = saveImage(Buffer.from("deleted"), "image/png");
const cleared = saveImage(Buffer.from("cleared"), "image/png");
const kept = saveImage(Buffer.from("kept"), "image/png");
const boxUploads = join(dirname(ATTACHMENTS_DIR), "box-uploads");
const shown = [join(boxUploads, "bot-a", "thread-1"), join(boxUploads, "bot-b", "thread-2")];
for (const dir of shown) {
mkdirSync(dir, { recursive: true });
for (const upload of [deleted, cleared, kept]) writeFileSync(join(dir, nameOf(upload.path)), "copy");
}
deleteAttachment(deleted.path);
age(cleared.path, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
clearAttachments(new Set([nameOf(kept.path)]));
for (const dir of shown) expect(readdirSync(dir)).toEqual([nameOf(kept.path)]);
rmSync(boxUploads, { recursive: true, force: true });
});
it("keeps an upload's box copies while the upload itself could not be deleted", () => {
const busy = saveImage(Buffer.from("busy"), "image/png");
const dir = join(dirname(ATTACHMENTS_DIR), "box-uploads", "bot-a", "thread-1");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, nameOf(busy.path)), "copy");
vi.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error("resource busy"), { code: "EBUSY" });
});
deleteAttachment(busy.path);
expect(existsSync(busy.path)).toBe(true);
expect(readdirSync(dir)).toEqual([nameOf(busy.path)]);
rmSync(join(dirname(ATTACHMENTS_DIR), "box-uploads"), { recursive: true, force: true });
});
it("does not point a Team Cloud, which has no clear, to Settings", () => {
const full = saveImage(Buffer.from("x"), "image/png");
truncateSync(full.path, ATTACHMENTS_MAX_BYTES);
__resetAttachmentAccountingForTests();
offerAttachmentClear(false);
try {
expect(() => saveImage(Buffer.from("y"), "image/png")).toThrow(/^attachments storage is full \(512 MB limit\)$/);
} finally {
offerAttachmentClear(true);
}
});
it("counts files it could not remove instead of hiding them", () => {
const busy = saveImage(Buffer.from("busy"), "image/png");
const gone = saveImage(Buffer.from("gone"), "image/png");
age(busy.path, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
age(gone.path, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
vi.mocked(unlinkSync).mockImplementation((path) => {
throw Object.assign(new Error("failed"), { code: String(path) === busy.path ? "EBUSY" : "ENOENT" });
});
expect(clearAttachments(new Set())).toEqual({ removed: 0, freedBytes: 0, failed: 1 });
});
it("leaves upload partials to their own cleanup", () => {
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const partial = join(ATTACHMENTS_DIR, `.openmaus-upload-${UPLOAD_A}-${UPLOAD_B}.partial`);
writeFileSync(partial, "part");
age(partial, ATTACHMENT_CLEAR_MIN_AGE_MS + 1_000);
expect(clearAttachments(new Set())).toEqual({ removed: 0, freedBytes: 0, failed: 0 });
expect(existsSync(partial)).toBe(true);
});
});