// What holds the downloads step (downloads/settings.ts): the app's download clients and jackett // feeds are made to say what the mesh bound — host, port, TLS, base path, user, credential — and // nothing else they keep is touched; only the mesh's entries are touched, and nothing is ever // deleted; nothing is written when nothing differs; a missing one is registered; and a credential // the provider refuses (the mesh's own minted value, before the operator accepts the provider's) // is never written, with the `secret accept` that fixes it named. // // The app and the providers are fakes: the routes the step touches, answering as the real ones do // (checked against the catalogue's pinned sonarr, radarr, lidarr, bookshelf, nzbget, qBittorrent // and jackett): the app masks a stored password as "********", tests an enabled entry before // saving it, refuses a warning unless forceSave, and jackett answers a wrong key 200 with an error. import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { CLIENTS, acceptRemedy, jackettFeed, providerTakes, reconcileClient, reconcileIndexers, stepSettings, wanted, type App, type Binding, type Endpoint, type Entry, type Http, } from "../downloads/settings.ts"; const NZBGET = CLIENTS.find((c) => c.provision === "nzbget-api")!; const QBIT = CLIENTS.find((c) => c.provision === "qbittorrent-api")!; const APP: App = { module: "sonarr", url: "http://127.0.0.1:8989", apiKey: "app-key", api: "v3" }; const MASK = "********"; interface Provider { host: string; port: number; user?: string; secret: string; configured?: Record; } const nzbget: Provider = { host: "ace.internal", port: 20201, user: "luffy", secret: "nzb-real" }; const qbit: Provider = { host: "ace.internal", port: 8112, user: "luffy", secret: "qbt-real" }; const jackett: Provider = { host: "ace.internal", port: 20204, secret: "jackett-real", configured: { rutracker: "RuTracker", torrent9: "Torrent9" } }; function binding(p: Provider, provision: string, extra: Record = {}): Binding { return { provision, from: "ace", at: p.host, as: "mesh_ace_sonarr", serves: { scheme: "http", port: p.port, "url-base": "", ...(p.user ? { username: p.user } : {}), ...extra }, }; } function endpoint(p: Provider, provision: string, credential = p.secret): Endpoint { const w = wanted(provision, binding(p, provision), credential, provision !== "jackett-api"); if (!w.ok) throw new Error(w.problem); return w.endpoint; } const f = (name: string, value: unknown) => ({ name, value }); function client(id: number, name: string, implementation: string, host: string, port: number, password: string, extra: Record = {}): Entry { return { id, name, implementation, enable: true, priority: 1, tags: [], fields: [f("host", host), f("port", port), f("useSsl", false), f("urlBase", null), f("username", "luffy"), f("password", password), f("tvCategory", "Series"), ...Object.entries(extra).map(([k, v]) => f(k, v))], }; } function feed(id: number, name: string, baseUrl: string, jid: string, key: string, on = true): Entry { return { id, name, implementation: "Torznab", enableRss: on, enableAutomaticSearch: on, enableInteractiveSearch: on, priority: 25, fields: [f("baseUrl", baseUrl), f("apiPath", `/api/v2.0/indexers/${jid}/results/torznab/`), f("apiKey", key), f("categories", [5000])], }; } interface Call { method: string; url: string; body?: unknown; } /** A Servarr app and the three providers behind one fetch. */ function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) { const calls: Call[] = []; const store: Record = { downloadclient: structuredClone(start.clients ?? []), indexer: structuredClone(start.indexers ?? []), }; let next = 100; const secretOf: Record = { downloadclient: "password", indexer: "apiKey" }; const masked = (e: Entry): Entry => ({ ...e, fields: e.fields!.map((x) => (x.name === "password" || x.name === "apiKey") && x.value ? { ...x, value: MASK } : { ...x }) }); const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value; /** What the app's own test says: dialled from its container, with its stored secret for a mask. */ const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => { let secret = val(e, secretOf[kind]); if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]); if (kind === "downloadclient") { const p = e.implementation === "Nzbget" ? nzbget : qbit; if (val(e, "host") !== p.host || val(e, "port") !== p.port) return [{ propertyName: "Host", errorMessage: "Unable to connect", isWarning: false }]; if (secret !== p.secret || val(e, "username") !== p.user) return [{ propertyName: "Username", errorMessage: "Authentication Failure", isWarning: false }]; if (val(e, "tvCategory") === "") return [{ propertyName: "TvCategory", errorMessage: "A category is recommended", isWarning: true }]; if (e.implementation === "Nzbget" && val(e, "tvCategory") === "tv") return [{ propertyName: "TvCategory", errorMessage: "Category does not exist", isWarning: false }]; return []; } const u = new URL(String(val(e, "baseUrl"))); if (u.hostname !== jackett.host || Number(u.port) !== jackett.port) return [{ propertyName: "BaseUrl", errorMessage: "Unable to connect", isWarning: false }]; if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }]; const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? ""; if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }]; return []; }; const http: Http = { async fetch(url, init) { const method = init?.method ?? "GET"; const body = init?.body && init.headers?.["Content-Type"] === "application/json" ? (JSON.parse(init.body) as Entry) : init?.body; calls.push({ method, url, body }); const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value)) }); const u = new URL(url); // Providers. if (u.pathname === "/jsonrpc/version") { const want = "Basic " + Buffer.from(`${nzbget.user}:${nzbget.secret}`).toString("base64"); return init?.headers?.Authorization === want ? reply(200, { result: "26.0" }) : reply(401); } if (u.pathname === "/api/v2/auth/login") { const form = new URLSearchParams(String(init?.body ?? "")); return form.get("username") === qbit.user && form.get("password") === qbit.secret ? reply(204) : reply(401, "Unauthorized"); } if (u.pathname.endsWith("/torznab/api")) { if (u.searchParams.get("apikey") !== jackett.secret) return reply(200, ''); const items = Object.entries(jackett.configured!).map(([id, t]) => `${t}`).join(""); return reply(200, `${items}`); } // The app. if (init?.headers?.["X-Api-Key"] !== APP.apiKey) return reply(401); const m = /^\/api\/v3\/(downloadclient|indexer)(?:\/(schema|test|\d+))?$/.exec(u.pathname); if (!m) return reply(404); const [, kind, sub] = m; const force = u.searchParams.get("forceSave") === "true"; if (method === "GET" && !sub) return reply(200, store[kind].map(masked)); if (method === "GET" && sub === "schema") { return reply(200, kind === "downloadclient" ? [client(0, "", "Nzbget", "localhost", 6789, MASK, {}), client(0, "", "QBittorrent", "localhost", 8080, "", {})].map((e) => ({ ...e, fields: e.fields!.map((x) => x.name === "tvCategory" ? { ...x, value: e.implementation === "Nzbget" ? "tv" : "tv-sonarr" } : x.name === "username" ? { ...x, value: null } : x) })) : [{ ...feed(0, "", "", "x", "", false), supportsRss: true, supportsSearch: true, fields: [f("baseUrl", null), f("apiPath", "/api"), f("apiKey", null), f("categories", [5030, 5040])] }]); } if (method === "GET") { const e = store[kind].find((s) => s.id === Number(sub)); return e ? reply(200, masked(e)) : reply(404); } if (method === "POST" && sub === "test") { const fails = appTest(kind, body as Entry); return fails.length ? reply(400, fails) : reply(200); } // Save: tested when enabled; a mask keeps what is stored. const e = body as Entry; const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true; if (on) { const fails = appTest(kind, e); if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails); } if (method === "POST" && !sub) { if (store[kind].some((s) => String(s.name).toLowerCase() === String(e.name).toLowerCase())) { return reply(400, [{ propertyName: "Name", errorMessage: "Should be unique", isWarning: false }]); } const created = { ...e, id: next++ }; store[kind].push(created); return reply(201, masked(created)); } if (method === "PUT") { const i = store[kind].findIndex((s) => s.id === Number(sub)); if (i < 0) return reply(404); const was = store[kind][i]; store[kind][i] = { ...e, fields: e.fields!.map((x) => x.value === MASK ? { ...x, value: val(was, x.name) } : x) }; return reply(202, masked(store[kind][i])); } return reply(405); }, }; const saves = () => calls.filter((c) => (c.method === "PUT" || (c.method === "POST" && !c.url.endsWith("/test"))) && c.url.includes("/api/v3/")); return { http, calls, store, saves }; } const aceClients = () => [ client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real"), client(2, "qBitTorrent", "QBittorrent", "qbittorrent", 8112, "qbt-real"), ]; test("the migration's download client is repointed, its category and everything else kept", async () => { const f = fakes({ clients: aceClients() }); const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient.map((e) => ({ ...e }))); assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "written", fields: ["host", "port"] }]); const saved = f.store.downloadclient.find((e) => e.id === 1)!; const v = (n: string) => saved.fields!.find((x) => x.name === n)?.value; assert.equal(v("host"), "ace.internal"); assert.equal(v("port"), 20201); assert.equal(v("tvCategory"), "Series"); assert.equal(v("password"), "nzb-real", "the password it held works, so it was kept, not rewritten"); assert.equal(saved.priority, 1); }); test("rerun: already as the mesh says, nothing saved", async () => { const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "ace.internal", 20201, "nzb-real")] }); const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient); assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "unchanged" }]); assert.equal(f.saves().length, 0); }); test("a stale password is replaced by the delivered one, which the provider took first", async () => { const f = fakes({ clients: [client(2, "qBitTorrent", "QBittorrent", "ace.internal", 8112, "old-pass")] }); const out = await reconcileClient(f.http, APP, QBIT, "qBitTorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient); assert.deepEqual(out, [{ what: 'qbittorrent-api ("qBitTorrent")', result: "written", fields: ["password"] }]); assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "password")?.value, "qbt-real"); }); test("a minted credential is refused by the provider: nothing written, the accept named", async () => { const f = fakes({ clients: aceClients() }); const ep = endpoint(nzbget, "nzbget-api", "a-value-the-mesh-minted"); assert.deepEqual(await providerTakes(f.http, "nzbget-api", ep), { took: false }); const remedy = acceptRemedy(APP, "ace", "nzbget-api", "nzbget", "ControlPassword", ep.from); assert.match(remedy, /`secret accept ace sonarr nzbget-api --provider ace --from `/); assert.doesNotMatch(remedy, /minted/); assert.equal(f.calls.some((c) => c.url.includes("/api/v3/")), false, "the app was not even asked"); const q = endpoint(qbit, "qbittorrent-api", "minted"); assert.deepEqual(await providerTakes(f.http, "qbittorrent-api", q), { took: false }); const j = endpoint(jackett, "jackett-api", "minted"); assert.deepEqual(await providerTakes(f.http, "jackett-api", j), { took: false }); assert.equal(f.calls.find((c) => c.url.includes("apikey="))?.url.includes("jackett-real"), false); }); test("a fresh app gets the mesh's client registered, under the mesh's name", async () => { const f = fakes({}); const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), []); assert.equal(out[0].result, "created"); const e = f.store.downloadclient[0]; assert.equal(e.name, "qbittorrent"); assert.equal(e.enable, true); assert.equal(e.fields!.find((x) => x.name === "tvCategory")?.value, "tv-sonarr", "the app's own default category"); }); test("nzbget without the app's default category: registered with none, and said", async () => { const f = fakes({}); const out = await reconcileClient(f.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), []); assert.equal(out[0].result, "notice"); assert.match((out[0] as { note: string }).note, /category left empty/); assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "tvCategory")?.value, ""); }); test("somebody else's entries are never touched: another name of the same kind, and a clash of names", async () => { const mine = client(7, "My seedbox", "QBittorrent", "seedbox.example", 443, "theirs"); const f = fakes({ clients: [mine] }); const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient); assert.equal(out[0].result, "notice"); assert.deepEqual(f.store.downloadclient.find((e) => e.id === 7), mine); // Same name in another case: refused, and the setting that adopts it named. const g = fakes({ clients: aceClients() }); const clash = await reconcileClient(g.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), g.store.downloadclient); assert.equal(clash[0].result, "refused"); assert.match((clash[0] as { problem: string }).problem, /downloads\.nzbget-api\.name to "NZBGet"/); assert.equal(g.saves().length, 0); }); test("jackett feeds: the migration's are repointed, a person's is left, a listed one is registered", async () => { const personal = feed(9, "Seedbox jackett", "https://jackett.seedbox.example", "rutracker", "their-key"); const f = fakes({ indexers: [ feed(5, "Torznab - RuTracker", "https://indexers.zurag.be", "rutracker-ru", "jackett-real", false), feed(6, "Torznab - Torrent9", "https://indexers.zurag.be", "torrent9", "jackett-real", false), feed(4, "Jackett - RARBG", "http://jackett:9117", "therarbg", "old", false), personal, ], }); const configured = new Map(Object.entries(jackett.configured!)); const out = await reconcileIndexers( f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: ["indexers.zurag.be", "jackett"], indexers: ["rutracker"] }, [], f.store.indexer, ); const byWhat = Object.fromEntries(out.map((o) => [o.what + ":" + o.result, o])); // torrent9: jackett has it; repointed, key already right, tested. assert.ok(byWhat['jackett-api ("Torznab - Torrent9", jackett indexer torrent9):written']); // rutracker-ru and therarbg: jackett has neither — repointed with the key, untested, and said. assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):written']); assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):notice']); // rutracker is listed and nothing of the mesh's reads it: registered. assert.ok(byWhat["jackett-api (jackett indexer rutracker):created"]); assert.deepEqual(f.store.indexer.find((e) => e.id === 9), personal, "a person's jackett is not the mesh's"); const t9 = f.store.indexer.find((e) => e.id === 6)!; assert.equal(t9.fields!.find((x) => x.name === "baseUrl")?.value, "http://ace.internal:20204"); assert.equal(t9.enableRss, false, "disabled stays disabled"); const created = f.store.indexer.find((e) => e.name === "Jackett - RuTracker")!; assert.equal(created.enableRss, true); assert.equal(f.calls.some((c) => c.method === "DELETE"), false, "nothing is ever deleted"); }); test("jackett feeds rerun: nothing saved, and a remembered host keeps a moved jackett's feeds", async () => { const f = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] }); const configured = new Map(Object.entries(jackett.configured!)); const out = await reconcileIndexers(f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["torrent9"] }, [], f.store.indexer); assert.deepEqual(out.map((o) => o.result), ["unchanged"]); assert.equal(f.saves().length, 0); // jackett moved to novox: the feed on ace.internal is still the mesh's because it was remembered. const moved = { ...jackett, host: "novox.internal" }; const g = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] }); const saved = jackett.host; jackett.host = moved.host; try { const again = await reconcileIndexers(g.http, APP, endpoint(moved, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, ["ace.internal"], g.store.indexer); assert.equal(again[0].result, "written"); assert.equal(g.store.indexer[0].fields!.find((x) => x.name === "baseUrl")?.value, "http://novox.internal:20204"); } finally { jackett.host = saved; } }); test("an enabled entry the app cannot test at the mesh's address is not saved", async () => { const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real")] }); const elsewhere = { ...nzbget, port: 1 }; const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(elsewhere, "nzbget-api"), f.store.downloadclient); assert.equal(out[0].result, "refused"); assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "host")?.value, "nzbget", "left as it was"); }); test("bindings: loopback, no user, no credential are refused; the base path is normalised", () => { assert.equal(wanted("nzbget-api", { ...binding(nzbget, "nzbget-api"), at: "127.0.0.1" }, "x", true).ok, false); assert.equal(wanted("nzbget-api", binding({ ...nzbget, user: undefined }, "nzbget-api"), "x", true).ok, false); assert.equal(wanted("nzbget-api", binding(nzbget, "nzbget-api"), " \n", true).ok, false); const w = wanted("jackett-api", binding(jackett, "jackett-api", { "url-base": "jackett/" }), "k", false); assert.equal(w.ok && w.endpoint.urlBase, "/jackett"); }); test("a feed is read whole: base path in the base URL or in the API path", () => { const e = feed(1, "x", "http://ace.internal:9117/jackett", "rutracker", "k"); assert.deepEqual(jackettFeed(e), { host: "ace.internal", id: "rutracker" }); assert.equal(jackettFeed({ ...e, implementation: "Newznab" }), undefined); assert.equal(jackettFeed({ ...e, fields: [f("baseUrl", "https://api.nzbgeek.info"), f("apiPath", "/api")] }), undefined); }); test("settings: names default to the provider's, adoption and registration read from the merged file", () => { assert.deepEqual(stepSettings({ node: "ace" }), { node: "ace", names: { "nzbget-api": "nzbget", "qbittorrent-api": "qbittorrent" }, indexers: { adoptHosts: [], indexers: [] } }); const s = stepSettings({ node: "ace", downloads: { "nzbget-api": { name: "NZBGet" }, "jackett-api": { "adopt-hosts": ["jackett"], indexers: ["torrent9", 3] } } }); assert.equal(s.names["nzbget-api"], "NZBGet"); assert.deepEqual(s.indexers, { adoptHosts: ["jackett"], indexers: ["torrent9"] }); }); // The four copies are one step. Where the siblings are checked out beside this module, they must // be byte-identical — a fix made in one and not the others is a bug in three apps. test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => { const here = dirname(dirname(fileURLToPath(import.meta.url))); const modules = dirname(here); for (const file of ["downloads/settings.ts", "downloads/index.ts", "test/downloads.test.ts"]) { const mine = readFileSync(join(here, file), "utf8"); for (const sibling of ["sonarr", "radarr", "lidarr", "bookshelf"]) { const theirs = join(modules, sibling, file); if (existsSync(theirs)) assert.equal(readFileSync(theirs, "utf8"), mine, `${sibling}/${file} differs`); } } });