uploads-route.test.js
5.45 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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// 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}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
let cookie;
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: "", ADMIN_PASSWORD },
stdio: "pipe",
});
await waitForServer(BASE);
const loginRes = await fetch(`${BASE}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
});
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 mp4 via /api/admin/upload-video returns a URL that GET /uploads/* serves back as video/mp4", async () => {
const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4"));
const fd = new FormData();
fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4");
const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd });
assert.equal(uploadRes.status, 200);
const { url } = await uploadRes.json();
assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.mp4$/);
const fetched = await fetch(`${BASE}${url}`);
assert.equal(fetched.status, 200);
assert.equal(fetched.headers.get("content-type"), "video/mp4");
});
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", headers: { Cookie: cookie }, 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", headers: { Cookie: cookie }, 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", headers: { Cookie: cookie }, 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);
});