fix: percent-encode X-File-Results header for non-ASCII filenames (#133)

Closes #133
This commit is contained in:
SnapOtter
2026-05-13 11:07:12 +08:00
14 changed files with 133 additions and 22 deletions
@@ -1104,7 +1104,7 @@ describe("Batch with mixed image formats (PNG + JPEG + WebP)", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(3);
});
@@ -649,7 +649,7 @@ describe("Batch edge cases — extended", () => {
// Should succeed and deduplicate filenames in the ZIP
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
// All three entries should have unique names
const names = Object.values(fileResults);
expect(new Set(names).size).toBe(3);
@@ -1524,7 +1524,7 @@ describe("Batch limits — boundary tests", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(1);
});
@@ -1546,7 +1546,7 @@ describe("Batch limits — boundary tests", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(10);
}, 120_000);
+2 -2
View File
@@ -1035,7 +1035,7 @@ describe("Batch -- all identical files", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(5);
});
@@ -1050,7 +1050,7 @@ describe("Batch -- all identical files", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const names = Object.values(fileResults);
// All three entries should have unique names in the ZIP
expect(new Set(names).size).toBe(3);
@@ -611,7 +611,9 @@ describe("Concurrent requests -- data integrity verification", () => {
// Batch request must succeed
expect(batchRes.statusCode).toBe(200);
expect(batchRes.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(batchRes.headers["x-file-results"] as string);
const fileResults = JSON.parse(
decodeURIComponent(batchRes.headers["x-file-results"] as string),
);
expect(Object.keys(fileResults).length).toBe(3);
// The single request's job ID must not appear in the batch results
+3 -3
View File
@@ -3102,7 +3102,7 @@ describe("Batch processing", () => {
});
expect(res.statusCode).toBe(200);
expect(res.headers["x-file-results"]).toBeDefined();
const parsed = JSON.parse(res.headers["x-file-results"] as string);
const parsed = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(parsed["0"]).toBeDefined();
expect(parsed["1"]).toBeDefined();
});
@@ -3189,7 +3189,7 @@ describe("Batch processing", () => {
const fileResults = res.headers["x-file-results"];
expect(fileResults).toBeDefined();
const parsed = JSON.parse(fileResults as string);
const parsed = JSON.parse(decodeURIComponent(fileResults as string));
expect(parsed["0"]).toBeDefined();
expect(parsed["1"]).toBeDefined();
expect(typeof parsed["0"]).toBe("string");
@@ -3215,7 +3215,7 @@ describe("Batch processing", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const names = [fileResults["0"], fileResults["1"], fileResults["2"]];
expect(names[0]).toContain("aaa");
expect(names[1]).toContain("bbb");
+82 -4
View File
@@ -130,12 +130,90 @@ describe("X-File-Results header", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults).toBeDefined();
// Should have entries for index 0 and 1
expect(fileResults["0"]).toBeDefined();
expect(fileResults["1"]).toBeDefined();
});
it("encodes non-ASCII filenames in header without ERR_INVALID_CHAR", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "图片测试.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "テスト.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("图片测试");
expect(fileResults["1"]).toContain("テスト");
});
it("handles mixed ASCII and non-ASCII filenames", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "normal.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "élève-photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "file", filename: "📷-snap.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("normal");
expect(fileResults["1"]).toContain("élève");
expect(fileResults["2"]).toContain("📷");
});
it("round-trips filenames with special URI characters", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "file with spaces & (parens).png",
contentType: "image/png",
content: PNG,
},
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
});
});
// ── ClientJobId passthrough ─────────────────────────────────────
@@ -363,7 +441,7 @@ describe("Exotic format batch processing", () => {
it(`processes ${ext.toUpperCase()} through batch compress`, async () => {
const res = await batchCompress(ext, mime);
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
});
}
@@ -408,7 +486,7 @@ describe("Exotic format batch processing", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
expect(fileResults["1"]).toBeDefined();
expect(fileResults["2"]).toBeDefined();
@@ -439,7 +517,7 @@ describe("Batch preserves upload order", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
// Index 0 should be derived from alpha, 1 from beta, 2 from gamma
expect(fileResults["0"]).toContain("alpha");
@@ -426,6 +426,33 @@ describe("Pipeline batch execution", () => {
expect(res.headers["x-file-results"]).toBeDefined();
});
it("handles non-ASCII filenames without ERR_INVALID_CHAR (#133)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "图片.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "写真テスト.png", contentType: "image/png", content: PNG_200x150 },
{
name: "pipeline",
content: JSON.stringify({
steps: [{ toolId: "resize", settings: { width: 50 } }],
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/batch",
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("图片");
expect(fileResults["1"]).toContain("写真テスト");
});
it("rejects pipeline batch with no files", async () => {
const { body, contentType } = createMultipartPayload([
{
+2 -2
View File
@@ -854,7 +854,7 @@ describe("svg-to-raster", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
// X-File-Results should contain deduplicated names
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const filenames = Object.values(fileResults) as string[];
// Filenames should be unique
expect(new Set(filenames).size).toBe(filenames.length);
@@ -1036,7 +1036,7 @@ describe("svg-to-raster", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(5);
});