// What holds bazarr's Servarr step (servarr/settings.ts): the connection bazarr keeps for Sonarr and // Radarr is made to say what the mesh bound — host, port, TLS, base path, key — and nothing else // bazarr keeps is sent; nothing is written when nothing differs; and a key the app refuses (the mesh's // own minted value, before the operator accepts the app's key) is never written, with the // `secret accept` that fixes it named. Also: bazarr's own key is found in its config.yaml's `auth` // section and not in the `sonarr`/`radarr` sections that also carry an `apikey`. // // bazarr and the apps are fakes answering as the real ones do (checked against // lscr.io/linuxserver/bazarr v1.6.1-ls364: GET/POST /api/system/settings with X-API-KEY, the form // keys `settings-
-`, 204 on save). import { test } from "node:test"; import assert from "node:assert/strict"; import { apiKeyFromConfigYaml } from "../apikey.ts"; import { APPS, baseUrlOf, differing, reconcileApp, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts"; const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp; const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp; const THE_KEY = "the-apps-own-key"; const BAZARR = { url: "http://127.0.0.1:6767", apiKey: "bazarr-key" }; function binding(provision: string, port: number, at = "ace.internal"): Binding { return { binding: 1, provision, from: "ace", at, as: "mesh_ace_bazarr", serves: { scheme: "http", port, "url-base": "" } } as Binding; } interface Call { method: string; url: string; body?: string; } /** bazarr's settings (one document, sections per app) and the apps' key check, behind one fetch. */ function fakes(settings: Record>, opts: { appKey?: string; reachable?: boolean } = {}) { const calls: Call[] = []; const appKey = opts.appKey ?? THE_KEY; const http: Http = { async fetch(url, init) { const method = init?.method ?? "GET"; calls.push({ method, url, body: init?.body }); const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : JSON.stringify(value)), }); const u = new URL(url); if (u.pathname.endsWith("/system/status")) { if (opts.reachable === false) throw new Error("connect ECONNREFUSED"); return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401); } if (init?.headers?.["X-API-KEY"] !== BAZARR.apiKey) return reply(401); if (u.pathname !== "/api/system/settings") return reply(404); if (method === "GET") return reply(200, settings); // bazarr's save_settings: split the key, cast as bazarr casts, store. for (const [k, raw] of new URLSearchParams(init?.body ?? "")) { const [, section, field] = k.split("-"); let v: unknown = raw; if (raw === "true") v = true; else if (raw === "false") v = false; else if (/^\d+$/.test(raw)) v = Number(raw); settings[section] = { ...(settings[section] ?? {}), [field]: v }; } return reply(204); }, }; return { http, calls, settings }; } /** ace's bazarr today: the apps by container name on HAL's shared network. */ function aceToday(): Record> { return { general: { use_sonarr: true, use_radarr: true, port: 6767 }, sonarr: { ip: "sonarr", port: 8989, ssl: false, base_url: "", apikey: THE_KEY, series_sync: 15, excluded_series_types: ["anime"] }, radarr: { ip: "radarr", port: 7878, ssl: false, base_url: "", apikey: THE_KEY, movies_sync: 15 }, }; } test("moving an app writes only host and port, and leaves every other setting alone", async () => { const f = fakes(aceToday()); const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY); assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port"] }); const post = f.calls.find((c) => c.method === "POST"); assert.ok(post); assert.deepEqual([...new URLSearchParams(post.body ?? "").keys()].sort(), ["settings-sonarr-ip", "settings-sonarr-port"]); assert.equal(f.settings.sonarr.ip, "ace.internal"); assert.equal(f.settings.sonarr.port, 20010); assert.deepEqual(f.settings.sonarr.excluded_series_types, ["anime"]); assert.equal(f.settings.radarr.ip, "radarr", "radarr is its own app and was not touched"); }); test("nothing is written when bazarr already says what the mesh says", async () => { const s = aceToday(); s.radarr = { ...s.radarr, ip: "ace.internal", port: 20011 }; const f = fakes(s); const out = await reconcileApp(f.http, BAZARR, RADARR, binding("radarr-api", 20011), THE_KEY); assert.deepEqual(out, { app: "radarr", result: "unchanged" }); assert.equal(f.calls.filter((c) => c.method === "POST").length, 0); }); test("a key the app refuses is never written, and the remedy is named", async () => { const f = fakes(aceToday()); const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), "a-value-the-mesh-minted"); assert.equal(out.result, "refused"); assert.match((out as { problem: string }).problem, /secret accept bazarr sonarr-api --provider ace/); assert.equal(f.calls.filter((c) => c.method === "POST").length, 0); assert.equal(f.settings.sonarr.apikey, THE_KEY, "the working key stays"); assert.equal(f.settings.sonarr.ip, "sonarr", "nothing moved either"); }); test("an unreachable app writes nothing", async () => { const f = fakes(aceToday(), { reachable: false }); const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY); assert.equal(out.result, "refused"); assert.equal(f.calls.filter((c) => c.method === "POST").length, 0); }); test("a loopback binding is refused: from bazarr's container that is bazarr", () => { const w = wanted(SONARR, binding("sonarr-api", 8989, "127.0.0.1"), THE_KEY); assert.equal(w.ok, false); }); test("a new key is written when the operator accepted a different one", async () => { const s = aceToday(); s.sonarr = { ...s.sonarr, ip: "ace.internal", port: 20010, apikey: "an-old-key" }; const f = fakes(s); const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY); assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["apikey"] }); assert.equal(f.settings.sonarr.apikey, THE_KEY); }); test("base paths compare as bazarr stores them", () => { assert.equal(baseUrlOf(""), ""); assert.equal(baseUrlOf("/"), ""); assert.equal(baseUrlOf("sonarr/"), "/sonarr"); const want = { ip: "a", port: 1, ssl: false, base_url: "", apikey: "k" }; assert.deepEqual(differing({ ip: "a", port: 1, ssl: false, base_url: "/", apikey: "k" }, want), []); }); test("bazarr's own key is auth.apikey, not an app's", () => { const yaml = [ "analytics:", " enabled: false", "auth:", " apikey: 0123456789abcdef0123456789abcdef", " password: ''", " type: form", "general:", " port: 6767", "sonarr:", " apikey: not-this-one", "", ].join("\n"); assert.equal(apiKeyFromConfigYaml(yaml), "0123456789abcdef0123456789abcdef"); assert.equal(apiKeyFromConfigYaml("sonarr:\n apikey: x\n"), undefined); assert.equal(apiKeyFromConfigYaml("auth:\n apikey: ''\n"), undefined); assert.equal(apiKeyFromConfigYaml("auth:\r\n apikey: 'abc'\r\n"), "abc"); });