- server: collapse leading slashes so //host/x is never proxied off-host or redirected cross-origin; 1 MB body limit; max 10k anchors; a corrupt alignment.json no longer 500s /api/songs (song falls back to auto with a warning); comments on cookie scope and the loopback inner server - warp (ruling A6): adding/moving a manual anchor removes autos only within 10 s (MIDI) of the new/old position, never past a manual, plus any auto that would break audio monotonicity - page: first anchor clamped at >= 0 s; ruler scrubbing seeks on release; wording for the new auto-removal rule; removed-count excludes the dragged anchor; shows server warnings - manifest: unique tmp name for alignment.json; clear error for a corrupt file - resample test: speed asserted as a ratio to the direct path Co-Authored-By: Claude Opus 5.5 <[email protected]>
223 lines
12 KiB
TypeScript
223 lines
12 KiB
TypeScript
import { test, expect, beforeAll, afterAll, describe } from "bun:test";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { startServer, type AlignServer } from "../../src/align/server.ts";
|
|
import { addSong } from "../../src/ref/manifest.ts";
|
|
import { writeSmf } from "../../src/align/smf.ts";
|
|
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
|
|
import type { Anchor } from "../../src/align/warp.ts";
|
|
|
|
const BIG = 120_000;
|
|
let root = "", srv: AlignServer, cookie = "";
|
|
const url = (p: string) => new URL(p, srv.origin).toString();
|
|
const api = (p: string, init: RequestInit = {}) => fetch(url(p), { ...init, headers: { cookie, ...(init.headers ?? {}) }, redirect: "manual" });
|
|
const json = (body: unknown, method = "POST"): RequestInit => ({ method, body: JSON.stringify(body), headers: { "content-type": "application/json" } });
|
|
|
|
beforeAll(async () => {
|
|
root = await mkdtemp(join(tmpdir(), "stsheets-align-"));
|
|
const dir = await buildSong("t1a", 1);
|
|
const { song, truth } = await loadSongDir(dir);
|
|
const midi = join(root, "fixture.mid");
|
|
await Bun.write(midi, writeSmf(truth.tracks.map((t, i) => ({ name: t.name, program: song.tracks[i]!.program, isDrums: t.role === "drums", notes: t.notes })), song.bpm));
|
|
await addSong({ id: "fix", title: "Fixture Song", audio: join(dir, "mix.wav"), midi, capo: {} }, root);
|
|
srv = await startServer({ root, host: "127.0.0.1", port: 0, quiet: true });
|
|
}, BIG);
|
|
afterAll(async () => { await srv?.stop(); if (root) await rm(root, { recursive: true, force: true }); });
|
|
|
|
describe("access token", () => {
|
|
test("requests without the token get 401", async () => {
|
|
for (const p of ["/", "/api/songs", "/api/song/fix", "/api/songs?key=wrong"]) expect((await fetch(url(p), { redirect: "manual" })).status).toBe(401);
|
|
expect((await fetch(url("/api/songs"), { headers: { cookie: "stsheets_align=nope" } })).status).toBe(401);
|
|
});
|
|
test("printed URL carries the token; ?key= sets an HttpOnly cookie and redirects to a clean URL", async () => {
|
|
expect(srv.url).toContain(`?key=${srv.token}`);
|
|
const r = await fetch(url(`/?key=${srv.token}`), { redirect: "manual" });
|
|
expect(r.status).toBe(302);
|
|
expect(r.headers.get("location")).toBe("/");
|
|
const sc = r.headers.get("set-cookie") ?? "";
|
|
expect(sc).toContain("HttpOnly");
|
|
cookie = sc.split(";")[0]!;
|
|
expect(cookie).toBe(`stsheets_align=${srv.token}`);
|
|
});
|
|
});
|
|
|
|
/** Raw HTTP/1.1 GET (Bun's fetch normalises "//" in paths, which would hide the bug under test). */
|
|
async function rawGet(path: string, cookieHeader = ""): Promise<{ status: number; headers: Record<string, string> }> {
|
|
const text = await new Promise<string>((resolve, reject) => {
|
|
let buf = "";
|
|
Bun.connect({
|
|
hostname: "127.0.0.1", port: srv.port,
|
|
socket: {
|
|
open(sk) { sk.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\n${cookieHeader ? `Cookie: ${cookieHeader}\r\n` : ""}Connection: close\r\n\r\n`); },
|
|
data(sk, d) {
|
|
buf += d.toString();
|
|
const end = buf.indexOf("\r\n\r\n"); // complete once headers and Content-Length bytes have arrived
|
|
const len = Number(buf.slice(0, end).match(/content-length:\s*(\d+)/i)?.[1] ?? 0);
|
|
if (end >= 0 && buf.length - end - 4 >= len) { sk.end(); resolve(buf); }
|
|
},
|
|
close() { resolve(buf); },
|
|
error(_, e) { reject(e); },
|
|
},
|
|
}).catch(reject);
|
|
});
|
|
const [head] = text.split("\r\n\r\n");
|
|
const [statusLine, ...lines] = head!.split("\r\n");
|
|
const headers = Object.fromEntries(lines.map((l) => { const i = l.indexOf(":"); return [l.slice(0, i).toLowerCase(), l.slice(i + 1).trim()]; }));
|
|
return { status: Number(statusLine!.split(" ")[1]), headers };
|
|
}
|
|
|
|
describe("paths with leading double slashes stay on this server", () => {
|
|
test("GET //example.invalid/ is not proxied off-host (served locally: 404)", async () => {
|
|
const r = await rawGet("//example.invalid/", cookie);
|
|
expect(r.status).toBe(404);
|
|
expect(r.headers.location).toBeUndefined();
|
|
});
|
|
test("GET //<this server>/ serves the local page, not a proxied fetch", async () => {
|
|
const r = await rawGet(`//127.0.0.1:${srv.port}/`, cookie);
|
|
expect(r.status).toBe(404); // collapsed to /127.0.0.1:<port>/ on the inner server
|
|
});
|
|
test("the ?key= redirect keeps a same-origin Location", async () => {
|
|
const r = await rawGet(`//example.invalid/x?key=${srv.token}`);
|
|
expect(r.status).toBe(302);
|
|
const loc = r.headers.location!;
|
|
expect(loc.startsWith("/")).toBe(true);
|
|
expect(loc.startsWith("//")).toBe(false);
|
|
expect(new URL(loc, srv.origin).origin).toBe(new URL(srv.origin).origin);
|
|
});
|
|
});
|
|
|
|
describe("page", () => {
|
|
test("GET / serves the HTML page and its bundled script", async () => {
|
|
const r = await api("/");
|
|
expect(r.status).toBe(200);
|
|
const html = await r.text();
|
|
expect(html).toContain("<canvas");
|
|
const src = html.match(/<script[^>]+src="([^"]+)"/)?.[1];
|
|
expect(src).toBeTruthy();
|
|
const js = await api(src!);
|
|
expect(js.status).toBe(200);
|
|
expect((await js.text()).length).toBeGreaterThan(1000);
|
|
expect((await fetch(url(src!))).status).toBe(401); // assets are behind the token too
|
|
});
|
|
});
|
|
|
|
describe("API", () => {
|
|
test("songs list", async () => {
|
|
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: false }]);
|
|
});
|
|
test("unknown and path-traversal ids are 404", async () => {
|
|
for (const p of ["/api/song/nope", "/api/song/%2E%2E", "/api/song/..%2F..%2Fetc", "/api/song/%2E%2E/audio", "/api/song/nope/render"]) {
|
|
expect((await api(p, p.endsWith("render") ? json({ anchors: [] }) : {})).status).toBe(404);
|
|
}
|
|
});
|
|
let first: any;
|
|
test("song: peaks, auto anchors (unsaved), stretches with MIDI and effective bpm, fit, tempo map", async () => {
|
|
const r = await api("/api/song/fix");
|
|
expect(r.status).toBe(200);
|
|
first = await r.json();
|
|
expect(first.id).toBe("fix");
|
|
expect(first.title).toBe("Fixture Song");
|
|
expect(first.audioDur).toBeGreaterThan(20);
|
|
expect(first.midiDur).toBeGreaterThan(20);
|
|
expect(first.tracks.length).toBeGreaterThan(0);
|
|
expect(first.tracks.every((t: any) => typeof t.name === "string" && "role" in t)).toBe(true);
|
|
expect(first.recordingPeaks.bucketSec).toBeGreaterThan(0);
|
|
expect(first.recordingPeaks.max.length).toBeGreaterThan(first.audioDur * 20);
|
|
expect(Math.max(...first.recordingPeaks.max)).toBeGreaterThan(0.05);
|
|
expect(first.source).toBe("auto");
|
|
expect(first.savedAt).toBeNull();
|
|
expect(first.anchors.length).toBeGreaterThan(2);
|
|
expect(first.anchors.every((a: Anchor) => a.kind === "auto")).toBe(true);
|
|
expect(first.stretches.length).toBeGreaterThanOrEqual(first.anchors.length); // +1, minus an empty edge stretch
|
|
expect(first.stretches[1].midiBpm).toBeGreaterThan(30);
|
|
expect(first.stretches[1].bpm).toBeCloseTo(first.stretches[1].midiBpm / first.stretches[1].ratio, 6);
|
|
expect(first.fit.matchRate).toBeGreaterThan(0.5);
|
|
expect(first.midiTempo[0].bpm).toBeGreaterThan(30);
|
|
expect(await Bun.file(join(root, "fix", "recording.wav")).exists()).toBe(true);
|
|
expect(await Bun.file(join(root, "fix", "peaks.json")).exists()).toBe(true);
|
|
}, BIG);
|
|
test("audio: cached Ogg Opus", async () => {
|
|
const r = await api("/api/song/fix/audio");
|
|
expect(r.status).toBe(200);
|
|
expect(r.headers.get("content-type")).toContain("audio/ogg");
|
|
const b = new Uint8Array(await r.arrayBuffer());
|
|
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("OggS");
|
|
expect(await Bun.file(join(root, "fix", "audio.ogg")).exists()).toBe(true);
|
|
}, BIG);
|
|
test("render: RIFF WAV, mono 22.05 kHz, cached by anchors", async () => {
|
|
const body = json({ anchors: first.anchors });
|
|
const r = await api("/api/song/fix/render", body);
|
|
expect(r.status).toBe(200);
|
|
expect(r.headers.get("content-type")).toBe("audio/wav");
|
|
const b = new Uint8Array(await r.arrayBuffer());
|
|
const dv = new DataView(b.buffer);
|
|
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("RIFF");
|
|
expect(dv.getUint16(22, true)).toBe(1);
|
|
expect(dv.getUint32(24, true)).toBe(22050);
|
|
expect(b.length).toBeGreaterThan(first.audioDur * 22050 * 2);
|
|
const again = await api("/api/song/fix/render", body);
|
|
expect(again.headers.get("x-cache")).toBe("hit");
|
|
expect(again.headers.get("x-anchors-hash")).toBe(r.headers.get("x-anchors-hash"));
|
|
}, BIG);
|
|
test("fit: re-scores arbitrary anchors", async () => {
|
|
const r = await api("/api/song/fix/fit", json({ anchors: [{ midiSec: 1, audioSec: 1.4, kind: "manual" }] }));
|
|
const j = await r.json();
|
|
expect(j.fit.windows.length).toBeGreaterThan(0);
|
|
expect(j.fit.matchRate).toBeLessThan(first.fit.matchRate);
|
|
expect(j.stretches.length).toBe(2);
|
|
});
|
|
test("bad anchors are rejected with 400", async () => {
|
|
const bad = [{ anchors: "x" }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "manual" }, { midiSec: 2, audioSec: 1, kind: "manual" }] }, { anchors: [{ midiSec: "1", audioSec: 2, kind: "manual" }] }, { anchors: [{ midiSec: 1, audioSec: 2, kind: "weird" }] }];
|
|
for (const b of bad) expect((await api("/api/song/fix/fit", json(b))).status).toBe(400);
|
|
expect((await api("/api/song/fix/fit", { method: "POST", body: "{not json" })).status).toBe(400);
|
|
});
|
|
test("auto: keeps manual anchors and re-solves autos around them", async () => {
|
|
const manual: Anchor = { ...first.anchors[3], kind: "manual" };
|
|
const r = await api("/api/song/fix/auto", json({ anchors: [manual, ...first.anchors.slice(5)] }));
|
|
const j = await r.json();
|
|
expect(j.anchors.filter((a: Anchor) => a.kind === "manual")).toEqual([manual]);
|
|
expect(j.anchors.filter((a: Anchor) => a.kind === "auto").length).toBeGreaterThan(2);
|
|
expect(j.fit.matchRate).toBeGreaterThan(0.5);
|
|
expect(j.stretches.length).toBeGreaterThanOrEqual(j.anchors.length);
|
|
}, BIG);
|
|
test("reset: raw MIDI (no anchors)", async () => {
|
|
const j = await (await api("/api/song/fix/reset", { method: "POST" })).json();
|
|
expect(j.anchors).toEqual([]);
|
|
expect(j.stretches).toHaveLength(1);
|
|
expect(j.fit.windows.length).toBeGreaterThan(0);
|
|
});
|
|
test("PUT alignment then GET round-trips anchors; file holds anchors, savedAt, fit", async () => {
|
|
const anchors: Anchor[] = [{ midiSec: 0.5, audioSec: 0.52, kind: "manual" }, { midiSec: 10, audioSec: 10.05, kind: "auto" }];
|
|
const p = await api("/api/song/fix/alignment", json({ anchors }, "PUT"));
|
|
expect(p.status).toBe(200);
|
|
const saved = await p.json();
|
|
expect(typeof saved.savedAt).toBe("string");
|
|
const file = await Bun.file(join(root, "fix", "alignment.json")).json();
|
|
expect(file.anchors).toEqual(anchors);
|
|
expect(file.savedAt).toBe(saved.savedAt);
|
|
expect(file.fit.windows.length).toBeGreaterThan(0);
|
|
const g = await (await api("/api/song/fix")).json();
|
|
expect(g.anchors).toEqual(anchors);
|
|
expect(g.source).toBe("saved");
|
|
expect(g.savedAt).toBe(saved.savedAt);
|
|
expect(await (await api("/api/songs")).json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: true }]);
|
|
});
|
|
test("more than 10k anchors is rejected with 400", async () => {
|
|
const many = Array.from({ length: 10_001 }, (_, k) => ({ midiSec: k * 0.01, audioSec: k * 0.01, kind: "auto" }));
|
|
expect((await api("/api/song/fix/fit", json({ anchors: many }))).status).toBe(400);
|
|
});
|
|
test("a corrupt alignment.json does not break the song list; the song falls back to auto with a warning", async () => {
|
|
await Bun.write(join(root, "fix", "alignment.json"), "{ not json");
|
|
const list = await api("/api/songs");
|
|
expect(list.status).toBe(200);
|
|
expect(await list.json()).toEqual([{ id: "fix", title: "Fixture Song", hasAlignment: false, alignmentError: expect.stringContaining("alignment.json") }]);
|
|
const g = await api("/api/song/fix");
|
|
expect(g.status).toBe(200);
|
|
const j = await g.json();
|
|
expect(j.source).toBe("auto");
|
|
expect(j.warning).toContain("alignment.json");
|
|
expect(j.anchors.length).toBeGreaterThan(2);
|
|
});
|
|
});
|