Files
linker/extension/extract.test.cjs
T
paulandClaude Opus 4.8 7adb99231a fix(security): client href scheme check, outbound fetch timeouts, origin-scoped CORS (closes task/resolver-security-hardening)
Source: task/resolver-security-hardening (plan/steward-linker-security) — defense-in-depth: client trusted resolver href, outbound fetches had no timeout, CORS was blanket-*.

- F1: extension/content.js makeLink re-validates primary.url scheme via a new
  pure Extract.isSafeHttpUrl helper (in extract.js, unit-tested); a non-http(s)
  url (javascript:/data:/garbage) is dropped and the plain text is kept.
- F2: each of the three resolver outbound fetches (Spotify token, Spotify
  search, ollama /api/chat) now carries signal: AbortSignal.timeout(5000) so a
  hung upstream fails fast (caught per-candidate -> null), zero-dep.
- F3: resolver CORS reflects the request Origin only for moz-extension://* or
  null (the extension's background-script origin), with Vary: Origin, instead
  of blanket access-control-allow-origin: *.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:29:42 +00:00

91 lines
4.3 KiB
JavaScript

// Unit tests for the pure candidate extraction (run with `node --test`). Zero-dep.
// CJS so it can require() extract.js, which is a UMD script (also a content script).
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn, isSafeHttpUrl } = require("./extract.js");
test("matchesIn finds Title-Case / ALL-CAPS runs, skips stopwords", () => {
// Note: "and" is a connector, so "X and Y" greedily joins into one run (by design,
// for names like "Iron and Wine") — keep the two names in separate clauses here.
const names = matchesIn("Cattle Decapitation is great; DEVOURMENT rules. I agree").map((m) => m.name);
assert.ok(names.includes("Cattle Decapitation"));
assert.ok(names.includes("DEVOURMENT"));
assert.ok(!names.includes("I"));
});
test("matchesIn reports positions that map back to the text", () => {
const text = "Check out Devourment now";
const m = matchesIn(text).find((x) => x.name === "Devourment");
assert.equal(text.substr(m.index, m.length), "Devourment");
});
test("cueMatchesIn catches lowercase names after a cue phrase", () => {
const names = cueMatchesIn("you should check out devourment honestly").map((m) => m.name);
assert.ok(names.includes("devourment"));
});
test("cueMatchesIn positions map back to the text", () => {
const text = "i'm a big fan of cattle decapitation"; // "fan of" cue
for (const m of cueMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
});
test("cueMatchesIn skips lowercase stopwords right after the cue", () => {
const names = cueMatchesIn("check out the new album").map((m) => m.name);
assert.ok(!names.includes("the"));
});
test("albumMatchesIn pulls quoted strings (double + single), excluding the quotes", () => {
const text = `their album "Reek of Putrefaction" and the 'Scum' demo`;
const ms = albumMatchesIn(text);
const names = ms.map((m) => m.name);
assert.ok(names.includes("Reek of Putrefaction"));
assert.ok(names.includes("Scum"));
// index/length cover the inner text only (no quote chars) so the wrapper links the title
for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name);
});
test("albumMatchesIn catches album cue phrases ('the album X', 'X LP/EP')", () => {
const a = albumMatchesIn("the album Scum is a classic").map((m) => m.name);
assert.ok(a.includes("Scum"));
const b = albumMatchesIn("Reign in Blood LP rules").map((m) => m.name);
assert.ok(b.includes("Reign in Blood"));
const c = albumMatchesIn("the record called Bonus Tracks").map((m) => m.name);
assert.ok(c.includes("Bonus Tracks"));
});
test("albumMatchesIn cue positions map back to the text", () => {
const text = "the album Scum is great";
for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
});
test("albumMatchesIn skips pure-number and too-short quotes", () => {
const names = albumMatchesIn(`"42" and "X"`).map((m) => m.name);
assert.ok(!names.includes("42"));
assert.ok(!names.includes("X")); // length < 2
});
test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => {
// accepted: the only schemes a resolver link may inject as an href
assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));
assert.ok(isSafeHttpUrl("http://localhost:8787/x"));
assert.ok(isSafeHttpUrl("https://www.google.com/search?q=Scum+bandcamp"));
// rejected: dangerous schemes + non-URLs
assert.ok(!isSafeHttpUrl("javascript:alert(1)"));
assert.ok(!isSafeHttpUrl("data:text/html,<script>alert(1)</script>"));
assert.ok(!isSafeHttpUrl("ftp://example.com/x"));
assert.ok(!isSafeHttpUrl("not a url"));
assert.ok(!isSafeHttpUrl(""));
assert.ok(!isSafeHttpUrl(null));
assert.ok(!isSafeHttpUrl(undefined));
});
test("allMatchesIn merges cue + Title-Case + album and sorts ascending by index", () => {
const text = `Check out devourment and Cattle Decapitation; the album "Human Jerky" rules`;
const ms = allMatchesIn(text);
for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index);
const names = ms.map((m) => m.name);
assert.ok(names.includes("devourment")); // cue (lowercase)
assert.ok(names.includes("Cattle Decapitation")); // title-case
assert.ok(names.includes("Human Jerky")); // quoted album
});