uploads-route.test.js 6.03 KB
// 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", "db.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);
});