1
11
12import { describe, expect, it } from "vitest";
13
14import {
15 buildFileKey,
16 buildThumbKey,
17 sanitizeFilename,
18 withCollisionSuffix,
19 parseFileKey,
20 parseThumbKey,
21 isWithinBucketPrefix,
22 workspaceFilePrefix,
23 workspaceThumbPrefix,
24 THUMBNAIL_SIZES,
25 THUMBNAIL_SIZE_WIDTH,
26 MAX_KEY_NAME_LENGTH,
27} from "./keys.js";
28
29describe("sanitizeFilename — TASK-0101 safeName matrix", () => {
30 it("../../etc/passwd → passwd", () => {
31 expect(sanitizeFilename("../../etc/passwd")).toBe("passwd");
32 });
33
34 it("strips absolute + windows paths", () => {
35 expect(sanitizeFilename("/etc/shadow")).toBe("shadow");
36 expect(sanitizeFilename("C:\\Windows\\system32\\evil.exe")).toBe("evil.exe");
37 expect(sanitizeFilename("..\\..\\x.txt")).toBe("x.txt");
38 });
39
40 it("replaces control chars and the unsafe set with _", () => {
41 expect(sanitizeFilename("a\u0000b.png")).toBe("a_b.png");
42 expect(sanitizeFilename("a\u0001\u0002c.txt")).toBe("a__c.txt");
43 expect(sanitizeFilename('we"ird:name?.txt')).toBe("we_ird_name_.txt");
44 expect(sanitizeFilename("a<1>b|c.txt")).toBe("a_1_b_c.txt");
45 });
46
47 it("backslash acts as a path separator (basename wins), then is replaced", () => {
48
49 expect(sanitizeFilename("bad\\slash.png")).toBe("slash.png");
50 expect(sanitizeFilename("a\\b\\c.d")).toBe("c.d");
51 expect(sanitizeFilename("C:\\Windows\\system32\\evil.exe")).toBe("evil.exe");
52 expect(sanitizeFilename("..\\..\\x.txt")).toBe("x.txt");
53
54 expect(sanitizeFilename("a\\")).toBe("file");
55 });
56
57 it("250-char name → ≤ 180 chars with extension kept", () => {
58 const long = "x".repeat(250);
59 const out = sanitizeFilename(`${long}.png`);
60 expect(out.length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH);
61 expect(out).toMatch(/\.png$/);
62
63 expect(sanitizeFilename(long).length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH);
64 });
65
66 it("UPPER.PNG keeps its extension (case-insensitive)", () => {
67 expect(sanitizeFilename("UPPER.PNG")).toBe("UPPER.PNG");
68 expect(sanitizeFilename("Photo.JPG")).toBe("Photo.JPG");
69 });
70
71 it("deterministic; empty/hidden-only → 'file'", () => {
72 expect(sanitizeFilename("a/b..c")).toBe(sanitizeFilename("a/b..c"));
73 expect(sanitizeFilename("")).toBe("file");
74 expect(sanitizeFilename("..")).toBe("file");
75 });
76
77 it("handles unicode without crashing", () => {
78 expect(() => sanitizeFilename("café-résumé.png")).not.toThrow();
79 });
80});
81
82describe("withCollisionSuffix", () => {
83 it("appends -<8 hex> and keeps the extension", () => {
84 expect(withCollisionSuffix("a.png", "deadbeef1234")).toBe("a-deadbeef.png");
85 expect(withCollisionSuffix("noext", "abcd")).toBe("noext-abcd0000");
86 });
87
88 it("keeps the segment within the cap for long names", () => {
89 const long = "x".repeat(300);
90 const out = withCollisionSuffix(sanitizeFilename(`${long}.png`), "deadbeef");
91 expect(out.length).toBeLessThanOrEqual(MAX_KEY_NAME_LENGTH);
92 expect(out).toMatch(/\.png$/);
93 });
94});
95
96describe("buildFileKey / parseFileKey (round-trip)", () => {
97 const variants = [
98 "report.png",
99 "my file (v2).png",
100 "café-résumé.pdf",
101 "a".repeat(300) + ".bin",
102 "noext",
103 ".hidden",
104 "archive.tar.gz",
105 "UPPER.CASE.JPG",
106 "dot..dots.txt",
107 "tricky\\name/file.dat",
108 ];
109
110 it("builds the spec layout", () => {
111 expect(buildFileKey({ workspaceId: "wsp_1", fileId: "fil_1", version: 1, name: "hello world.png" })).toBe(
112 "files/wsp_1/fil_1/1/hello world.png",
113 );
114 });
115
116 it("sanitizes the name inside the key", () => {
117 expect(
118 buildFileKey({ workspaceId: "wsp_1", fileId: "f", version: 2, name: "../../etc/passwd" }),
119 ).toBe("files/wsp_1/f/2/passwd");
120 });
121
122 it("round-trips 10 name variants through parseFileKey", () => {
123 for (const [i, name] of variants.entries()) {
124 const key = buildFileKey({ workspaceId: "wsp_abc123", fileId: "file_xyz789", version: i + 1, name });
125 expect(key.startsWith(`files/wsp_abc123/file_xyz789/${i + 1}/`)).toBe(true);
126 const parsed = parseFileKey(key);
127 expect(parsed.workspaceId).toBe("wsp_abc123");
128 expect(parsed.fileId).toBe("file_xyz789");
129 expect(parsed.version).toBe(i + 1);
130 expect(parsed.name.includes("/")).toBe(false);
131 expect(parsed.name.includes("\\")).toBe(false);
132 }
133 });
134
135 it("rejects version < 1", () => {
136 expect(() => buildFileKey({ workspaceId: "w", fileId: "f", version: 0, name: "a" })).toThrow();
137 expect(() => buildFileKey({ workspaceId: "w", fileId: "f", version: -1, name: "a" })).toThrow();
138 });
139
140 it("parseFileKey rejects malformed keys", () => {
141 const bad = [
142 "",
143 "files",
144 "files/wsp_a",
145 "files/wsp_a/f",
146 "files/wsp_a/f/1",
147 "files/wsp_a/f/0/a.png",
148 "files/wsp_a/f/x/a.png",
149 "files/wsp_a/f/1/sub/dir.png",
150 "thumbs/wsp_a/f/480/a.png",
151 "other/prefix/x.png",
152 ];
153 for (const key of bad) {
154 expect(() => parseFileKey(key)).toThrow(/malformed/i);
155 }
156 });
157});
158
159describe("buildThumbKey / parseThumbKey (round-trip)", () => {
160 it("builds the spec layout (name + output ext)", () => {
161
162 expect(buildThumbKey({ workspaceId: "wsp_1", fileId: "fil_1", size: "480", name: "a", ext: "png" })).toBe(
163 "thumbs/wsp_1/fil_1/480/a.png",
164 );
165 });
166
167 it("all three sizes", () => {
168 for (const size of THUMBNAIL_SIZES) {
169 expect(
170 buildThumbKey({ workspaceId: "w", fileId: "f", size, name: "pic", ext: "png" }),
171 ).toBe(`thumbs/w/f/${size}/pic.png`);
172 }
173 });
174
175 it("normalizes the ext (leading dot, case)", () => {
176 expect(buildThumbKey({ workspaceId: "w", fileId: "f", size: "160", name: "a", ext: ".JPEG" })).toBe(
177 "thumbs/w/f/160/a.jpeg",
178 );
179 });
180
181 it("rejects unknown sizes", () => {
182 expect(() =>
183 buildThumbKey({ workspaceId: "w", fileId: "f", size: "999" as never, name: "a", ext: "png" }),
184 ).toThrow();
185 });
186
187 it("round-trips", () => {
188 const key = buildThumbKey({ workspaceId: "wsp_2", fileId: "fil_8", size: "1280", name: "b", ext: "png" });
189 expect(key).toBe("thumbs/wsp_2/fil_8/1280/b.png");
190 expect(parseThumbKey(key)).toEqual({
191 workspaceId: "wsp_2",
192 fileId: "fil_8",
193 size: "1280",
194 name: "b",
195 ext: "png",
196 });
197 });
198
199 it("parseThumbKey rejects malformed keys", () => {
200 for (const key of ["", "thumbs", "thumbs/w", "thumbs/w/f", "thumbs/w/f/999/a.png", "files/w/f/1/a.png"]) {
201 expect(() => parseThumbKey(key)).toThrow(/malformed/i);
202 }
203 });
204
205 it("size widths", () => {
206 expect(THUMBNAIL_SIZES).toEqual(["160", "480", "1280"]);
207 expect(THUMBNAIL_SIZE_WIDTH).toEqual({ "160": 160, "480": 480, "1280": 1280 });
208 });
209});
210
211describe("workspace prefixes (isolation by key prefix, not bucket ACL)", () => {
212 it("file prefix scopes the workspace tree", () => {
213 expect(workspaceFilePrefix("wsp_1")).toBe("files/wsp_1/");
214 expect(isWithinBucketPrefix("files/wsp_1/fil_1/1/a.png", workspaceFilePrefix("wsp_1"))).toBe(true);
215 expect(isWithinBucketPrefix("files/wsp_2/fil_1/1/a.png", workspaceFilePrefix("wsp_1"))).toBe(false);
216 });
217
218 it("thumb prefix scopes the workspace tree", () => {
219 expect(workspaceThumbPrefix("wsp_1")).toBe("thumbs/wsp_1/");
220 expect(isWithinBucketPrefix("thumbs/wsp_1/fil_1/480/a.png", workspaceThumbPrefix("wsp_1"))).toBe(true);
221 expect(isWithinBucketPrefix("files/wsp_1/fil_1/1/a.png", workspaceThumbPrefix("wsp_1"))).toBe(false);
222 });
223
224 it("isWithinBucketPrefix boundary semantics", () => {
225 expect(isWithinBucketPrefix("files/wsp_1", "files/wsp_1")).toBe(true);
226 expect(isWithinBucketPrefix("files/wsp_1x", "files/wsp_1")).toBe(false);
227 expect(isWithinBucketPrefix("files/wsp_1/f/1/a", "files/wsp_1")).toBe(true);
228 });
229});
230