/** * @factory/storage — keys.test.ts (key layout + safeName matrix). * * Key layout per TASK-0101 Requirement 2: * files/{workspaceId}/{fileId}/{version}/{safeName} * thumbs/{workspaceId}/{fileId}/{size}/{safeName} * * The `safeName` matrix is a TASK-0101 acceptance criterion — path traversal * must be impossible by construction. */ import { describe, expect, it } from "vitest"; import { buildFileKey, buildThumbKey, sanitizeFilename, withCollisionSuffix, parseFileKey, parseThumbKey, isWithinBucketPrefix, workspaceFilePrefix, workspaceThumbPrefix, THUMBNAIL_SIZES, THUMBNAIL_SIZE_WIDTH, MAX_KEY_NAME_LENGTH, } from "./keys.js"; describe("sanitizeFilename — TASK-0101 safeName matrix", () => { it("../../etc/passwd → passwd", () => { expect(sanitizeFilename("../../etc/passwd")).toBe("passwd"); }); it("strips absolute + windows paths", () => { expect(sanitizeFilename("/etc/shadow")).toBe("shadow"); expect(sanitizeFilename("C:\\Windows\\system32\\evil.exe")).toBe("evil.exe"); expect(sanitizeFilename("..\\..\\x.txt")).toBe("x.txt"); }); it("replaces control chars and the unsafe set with _", () => { expect(sanitizeFilename("a\u0000b.png")).toBe("a_b.png"); expect(sanitizeFilename("a\u0001\u0002c.txt")).toBe("a__c.txt"); expect(sanitizeFilename('we"ird:name?.txt')).toBe("we_ird_name_.txt"); expect(sanitizeFilename("a<1>b|c.txt")).toBe("a_1_b_c.txt"); }); it("backslash acts as a path separator (basename wins), then is replaced", () => { // Windows-style drag-and-drop paths keep only the basename. expect(sanitizeFilename("bad\\slash.png")).toBe("slash.png"); expect(sanitizeFilename("a\\b\\c.d")).toBe("c.d"); expect(sanitizeFilename("C:\\Windows\\system32\\evil.exe")).toBe("evil.exe"); expect(sanitizeFilename("..\\..\\x.txt")).toBe("x.txt"); // A separator with no name after it → empty basename → 'file'. expect(sanitizeFilename("a\\")).toBe("file"); }); it("250-char name → ≤ 180 chars with extension kept", () => { const long = "x".repeat(250); const out = sanitizeFilename(`${long}.png`); expect(out.length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH); expect(out).toMatch(/\.png$/); // no-extension long names are capped too expect(sanitizeFilename(long).length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH); }); it("UPPER.PNG keeps its extension (case-insensitive)", () => { expect(sanitizeFilename("UPPER.PNG")).toBe("UPPER.PNG"); expect(sanitizeFilename("Photo.JPG")).toBe("Photo.JPG"); }); it("deterministic; empty/hidden-only → 'file'", () => { expect(sanitizeFilename("a/b..c")).toBe(sanitizeFilename("a/b..c")); expect(sanitizeFilename("")).toBe("file"); expect(sanitizeFilename("..")).toBe("file"); }); it("handles unicode without crashing", () => { expect(() => sanitizeFilename("café-résumé.png")).not.toThrow(); }); }); describe("withCollisionSuffix", () => { it("appends -<8 hex> and keeps the extension", () => { expect(withCollisionSuffix("a.png", "deadbeef1234")).toBe("a-deadbeef.png"); expect(withCollisionSuffix("noext", "abcd")).toBe("noext-abcd0000"); }); it("keeps the segment within the cap for long names", () => { const long = "x".repeat(300); const out = withCollisionSuffix(sanitizeFilename(`${long}.png`), "deadbeef"); expect(out.length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH); expect(out).toMatch(/\.png$/); }); }); describe("buildFileKey / parseFileKey (round-trip)", () => { const variants = [ "report.png", "my file (v2).png", "café-résumé.pdf", "a".repeat(300) + ".bin", "noext", ".hidden", "archive.tar.gz", "UPPER.CASE.JPG", "dot..dots.txt", "tricky\\name/file.dat", ]; it("builds the spec layout", () => { expect(buildFileKey({ workspaceId: "wsp_1", fileId: "fil_1", version: 1, name: "hello world.png" })).toBe( "files/wsp_1/fil_1/1/hello world.png", ); }); it("sanitizes the name inside the key", () => { expect( buildFileKey({ workspaceId: "wsp_1", fileId: "f", version: 2, name: "../../etc/passwd" }), ).toBe("files/wsp_1/f/2/passwd"); }); it("round-trips 10 name variants through parseFileKey", () => { for (const [i, name] of variants.entries()) { const key = buildFileKey({ workspaceId: "wsp_abc123", fileId: "file_xyz789", version: i + 1, name }); expect(key.startsWith(`files/wsp_abc123/file_xyz789/${i + 1}/`)).toBe(true); const parsed = parseFileKey(key); expect(parsed.workspaceId).toBe("wsp_abc123"); expect(parsed.fileId).toBe("file_xyz789"); expect(parsed.version).toBe(i + 1); expect(parsed.name.includes("/")).toBe(false); expect(parsed.name.includes("\\")).toBe(false); } }); it("rejects version < 1", () => { expect(() => buildFileKey({ workspaceId: "w", fileId: "f", version: 0, name: "a" })).toThrow(); expect(() => buildFileKey({ workspaceId: "w", fileId: "f", version: -1, name: "a" })).toThrow(); }); it("parseFileKey rejects malformed keys", () => { const bad = [ "", "files", "files/wsp_a", "files/wsp_a/f", "files/wsp_a/f/1", // no name "files/wsp_a/f/0/a.png", // version 0 "files/wsp_a/f/x/a.png", // non-numeric version "files/wsp_a/f/1/sub/dir.png", // path in name "thumbs/wsp_a/f/480/a.png", // wrong bucket prefix "other/prefix/x.png", ]; for (const key of bad) { expect(() => parseFileKey(key)).toThrow(/malformed/i); } }); }); describe("buildThumbKey / parseThumbKey (round-trip)", () => { it("builds the spec layout (name + output ext)", () => { // `name` is the artifact stem — the source extension is NOT repeated. expect(buildThumbKey({ workspaceId: "wsp_1", fileId: "fil_1", size: "480", name: "a", ext: "png" })).toBe( "thumbs/wsp_1/fil_1/480/a.png", ); }); it("all three sizes", () => { for (const size of THUMBNAIL_SIZES) { expect( buildThumbKey({ workspaceId: "w", fileId: "f", size, name: "pic", ext: "png" }), ).toBe(`thumbs/w/f/${size}/pic.png`); } }); it("normalizes the ext (leading dot, case)", () => { expect(buildThumbKey({ workspaceId: "w", fileId: "f", size: "160", name: "a", ext: ".JPEG" })).toBe( "thumbs/w/f/160/a.jpeg", ); }); it("rejects unknown sizes", () => { expect(() => buildThumbKey({ workspaceId: "w", fileId: "f", size: "999" as never, name: "a", ext: "png" }), ).toThrow(); }); it("round-trips", () => { const key = buildThumbKey({ workspaceId: "wsp_2", fileId: "fil_8", size: "1280", name: "b", ext: "png" }); expect(key).toBe("thumbs/wsp_2/fil_8/1280/b.png"); expect(parseThumbKey(key)).toEqual({ workspaceId: "wsp_2", fileId: "fil_8", size: "1280", name: "b", ext: "png", }); }); it("parseThumbKey rejects malformed keys", () => { for (const key of ["", "thumbs", "thumbs/w", "thumbs/w/f", "thumbs/w/f/999/a.png", "files/w/f/1/a.png"]) { expect(() => parseThumbKey(key)).toThrow(/malformed/i); } }); it("size widths", () => { expect(THUMBNAIL_SIZES).toEqual(["160", "480", "1280"]); expect(THUMBNAIL_SIZE_WIDTH).toEqual({ "160": 160, "480": 480, "1280": 1280 }); }); }); describe("workspace prefixes (isolation by key prefix, not bucket ACL)", () => { it("file prefix scopes the workspace tree", () => { expect(workspaceFilePrefix("wsp_1")).toBe("files/wsp_1/"); expect(isWithinBucketPrefix("files/wsp_1/fil_1/1/a.png", workspaceFilePrefix("wsp_1"))).toBe(true); expect(isWithinBucketPrefix("files/wsp_2/fil_1/1/a.png", workspaceFilePrefix("wsp_1"))).toBe(false); }); it("thumb prefix scopes the workspace tree", () => { expect(workspaceThumbPrefix("wsp_1")).toBe("thumbs/wsp_1/"); expect(isWithinBucketPrefix("thumbs/wsp_1/fil_1/480/a.png", workspaceThumbPrefix("wsp_1"))).toBe(true); expect(isWithinBucketPrefix("files/wsp_1/fil_1/1/a.png", workspaceThumbPrefix("wsp_1"))).toBe(false); }); it("isWithinBucketPrefix boundary semantics", () => { expect(isWithinBucketPrefix("files/wsp_1", "files/wsp_1")).toBe(true); expect(isWithinBucketPrefix("files/wsp_1x", "files/wsp_1")).toBe(false); expect(isWithinBucketPrefix("files/wsp_1/f/1/a", "files/wsp_1")).toBe(true); }); });