// #221 — unit tests for the upload primitives. The HTTP wire-up + Bun // multipart parsing lives in server/src/index.ts; this file pins the // path / regex / rate-limit / attachment-schema invariants that the // 6-item security checklist depends on. // // Coverage matrix: // 1. file_id regex accepts server-generated ids + rejects traversal attempts // 2. sanitizeExt strips client-side filename paths, lowercases ext // 3. buildStoragePath rejects malformed id / ext, places file under date bucket // 4. isPathInsideUploadsRoot defends against symlink / traversal escape // 5. UploadRateLimiter — first call allowed, exhausting bucket blocks, window expiry resets // 6. validateAttachments accepts MCP-compatible shape, rejects too-many / bad type / bad file_id // 7. validateIndexEntry refuses tampered shapes // 8. indexEntryPath rejects malformed file_id import { describe, expect, test } from "bun:test"; import { FILE_ID_REGEX, MAX_UPLOAD_BYTES, UploadRateLimiter, UPLOAD_RATE_MAX_PER_WINDOW, UPLOAD_RATE_WINDOW_MS, buildStoragePath, generateFileId, getDateBucket, indexEntryPath, isPathInsideUploadsRoot, sanitizeExt, validateAttachments, validateIndexEntry, } from "./uploads"; describe("FILE_ID_REGEX", () => { test("accepts the server-generated uuidv4-without-hyphens shape (32 hex chars)", () => { for (let i = 0; i < 20; i++) { const id = generateFileId(); expect(FILE_ID_REGEX.test(id)).toBe(true); expect(id.length).toBe(32); } }); test("rejects every directory-traversal attempt a client might try", () => { const traversals = [ "../foo", "..%2Ffoo", "foo/bar", "foo\\bar", "foo.bar", ".bashrc", "/etc/passwd", "foo bar", "", "abc", // too short "x".repeat(65), // too long ]; for (const t of traversals) { expect(FILE_ID_REGEX.test(t)).toBe(false); } }); }); describe("sanitizeExt", () => { test("returns a leading-dot lowercase extension for a normal filename", () => { expect(sanitizeExt("photo.PNG")).toBe(".png"); expect(sanitizeExt("report.pdf")).toBe(".pdf"); expect(sanitizeExt("archive.tar.gz")).toBe(".gz"); }); test("strips any client-supplied directory prefix before reading the ext", () => { // The handler must NEVER use the client filename for storage paths, // but the ext is allowed through for nicer URLs. We still strip any // path separator the client tries to sneak in. expect(sanitizeExt("../../etc/passwd.jpg")).toBe(".jpg"); expect(sanitizeExt("..\\..\\windows\\boot.ini")).toBe(".ini"); expect(sanitizeExt("foo/bar/baz.html")).toBe(".html"); }); test("returns empty string for missing / weird inputs", () => { expect(sanitizeExt(undefined)).toBe(""); expect(sanitizeExt(null)).toBe(""); expect(sanitizeExt("")).toBe(""); expect(sanitizeExt("noext")).toBe(""); // ".bashrc" → ".bashrc" is acceptable: storage uses the server-generated // file_id, so the file lands as ".bashrc". The download handler // forces Content-Disposition: attachment + X-Content-Type-Options: nosniff, // so the ext can't be leveraged to execute / mis-render the file. expect(sanitizeExt(".bashrc")).toBe(".bashrc"); // ext > 16 chars is rejected; "extensiontoolong1" is 17 chars. expect(sanitizeExt("foo.extensiontoolong1")).toBe(""); }); test("rejects exts with non-alphanumeric characters", () => { expect(sanitizeExt("payload.exe;.png")).toBe(".png"); expect(sanitizeExt("foo.