uploads-route.test.js
6.02 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
137
138
139
140
141
142
143
144
145
146
147
// 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 os = require("node:os");
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;
let cwd;
test.before(async () => {
// server.js resolves uploads/ relative to its own __dirname, not PORT —
// a past version of this test spawned server.js with cwd: __dirname
// (the real repo) and then rm -rf'd the whole uploads/ directory in
// test.after as "cleanup", which wiped real uploaded content on every
// `node --test` run. Spawn from an isolated tmpdir copy instead, same
// pattern as dressup-generate.test.js / dance-channel.test.js, so this
// test can never touch the real repo's uploads/.
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "uploads-route-test-"));
for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
}
child = spawn(process.execPath, ["server.js"], {
cwd,
// 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();
fs.rmSync(cwd, { 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);
});