uploads-route.test.js
4.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// Integration test for POST /api/admin/upload + GET /uploads/* (dev-plan.md
// T2 acceptance criteria). Spawns server.js as a child process on a throwaway
// port so this never touches the real pm2-managed instance/port (8934) or
// its library.json.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const http = require("node:http");
// A real, valid 2x2 PNG (not just the 8-byte magic signature) — needed
// because sips has to actually decode it during stripMetadataAndSave.
const TINY_VALID_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
"base64"
);
const PORT = 18934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
function waitForServer(url, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
return (async function poll() {
while (Date.now() < deadline) {
try {
await fetch(url);
return;
} catch {
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error("server did not come up in time");
})();
}
let child;
test.before(async () => {
child = spawn(process.execPath, ["server.js"], {
cwd: __dirname,
// H3_CONSOLE_BASE cleared: this test only exercises the upload route,
// no need for the spawned copy to also hit the real H3 Console /
// race the live pm2 instance on library.json's periodic sync.
env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "" },
stdio: "pipe",
});
await waitForServer(BASE);
});
test.after(() => {
child.kill();
// uploads/ written by this test run lands under the real repo path
// (server.js resolves it relative to __dirname, not PORT) — clean it up.
const dir = path.join(__dirname, "uploads");
fs.rmSync(dir, { recursive: true, force: true });
});
test("uploading a real PNG returns a URL that GET /uploads/* serves back with the right content-type", async () => {
const fd = new FormData();
fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png");
const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd });
assert.equal(uploadRes.status, 200);
const { url } = await uploadRes.json();
assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/);
const fetched = await fetch(`${BASE}${url}`);
assert.equal(fetched.status, 200);
assert.equal(fetched.headers.get("content-type"), "image/png");
});
test("a zip disguised as .jpg is rejected regardless of filename/content-type claimed by the client", async () => {
const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0]);
const fd = new FormData();
fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg");
const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd });
assert.equal(res.status, 400);
});
test("a file over 8MB is rejected", async () => {
const oversized = Buffer.alloc(8 * 1024 * 1024 + 1, 0);
// still needs a real JPEG magic-byte prefix so this test isn't accidentally
// passing for the wrong reason (magic-byte rejection instead of size).
oversized[0] = 0xff;
oversized[1] = 0xd8;
oversized[2] = 0xff;
const fd = new FormData();
fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg");
await assert.rejects(
() => fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }),
/fetch failed|ECONNRESET|terminated/i
);
});
test("GET /uploads/* rejects path traversal", async () => {
// fetch()/the URL parser normalize "../" out of the path before the
// request ever leaves the client, which would silently defeat this test
// (it'd request "/server.js", not exercise the /uploads/ handler at all).
// A raw http.request with a hand-built request line preserves the literal
// ".." so this actually exercises the guard in server.js.
const status = await new Promise((resolve, reject) => {
const req = http.request(
{ host: "localhost", port: PORT, path: "/uploads/../server.js", method: "GET" },
(res) => resolve(res.statusCode)
);
req.on("error", reject);
req.end();
});
assert.equal(status, 403);
});