// 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); });