Files
mesh-media-catalog/modules/ombi/test/plex.test.ts
T
jschoubben db3adfa4c6 The media chain as mesh modules, in one catalogue
sonarr, radarr, lidarr, bazarr, nzbget, qbittorrent, jackett, bookshelf,
plex, tautulli, kometa and ombi, taken from the novox/mesh-catalog branches
that prepared them for ace (PRs 145-168), consolidated in stack order.
kometa gains a minimal runtime sidecar (kometa_status, kometa_config) and
declares its tmdb key as an own secret instead of a "secret" requirement.
2026-09-30 21:32:59 +02:00

224 lines
11 KiB
TypeScript

// What holds ombi's Plex step (plex/settings.ts): the entry for the server plex says it is — found
// by machineIdentifier — is made to say what the mesh bound (host, port, TLS, token) and nothing
// else it keeps is touched; an entry for another server is left alone; an ombi with no entry for it
// gets one; nothing is written when nothing differs; and a token plex refuses (the mesh's own minted
// value, before the operator accepts the server's token) is never written, with the `secret accept`
// that fixes it named.
//
// ombi and plex are fakes answering the routes the step touches as the real ones do (checked against
// lscr.io/linuxserver/ombi 4.53.10 and plexinc/pms-docker 1.43.4: plex answers 401 to an unknown
// token from another network and 400 on one it trusts; ombi's /Tester/plex answers a bare boolean).
//
// Imports the compiled step, as keycloak's tests do: plex/settings.ts imports its sibling with the
// `.js` specifier the build needs, which Node's type stripping does not resolve to a `.ts` file.
import { test } from "node:test";
import assert from "node:assert/strict";
import { differingPlex, reconcilePlex, wantedPlex, withPlexServer } from "../dist/plex/settings.js";
import type { Binding, Http } from "../servarr/settings.ts";
const TOKEN = "the-servers-own-token";
const MACHINE = "5c47d9a165d10b622995d55b3ae1f168242f33bd";
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
function binding(port = 32400, at = "ace.internal", scheme = "http"): Binding {
return { binding: 1, provision: "plex-api", from: "ace", at, as: "mesh_ace_ombi", serves: { scheme, port } } as Binding;
}
interface Call {
method: string;
url: string;
body?: unknown;
}
function fakes(plexSettings: Record<string, unknown>, opts: { reachable?: boolean; trusted?: boolean } = {}) {
const calls: Call[] = [];
const store = { plex: plexSettings };
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
// Other servers an entry may name: a friend's, and plex's own public name (the same server).
if (u.hostname === "10.0.0.9") return reply(200, { MediaContainer: { machineIdentifier: "another-server" } });
if (u.hostname === "gone.example") throw new Error("getaddrinfo ENOTFOUND");
if (u.hostname === "plex.zurag.be") {
if (u.pathname === "/identity") return reply(200, { MediaContainer: { machineIdentifier: MACHINE } });
return reply(401);
}
if (u.port === "32400" || u.hostname === "ace.internal") {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
if (u.pathname === "/identity") return reply(200, { MediaContainer: { machineIdentifier: MACHINE } });
const token = init?.headers?.["X-Plex-Token"];
if (token !== TOKEN) return reply(opts.trusted ? 400 : 401);
return reply(200, { MediaContainer: { friendlyName: "ace", machineIdentifier: MACHINE } });
}
if (init?.headers?.ApiKey !== "ombi-key") return reply(401);
if (u.pathname === "/api/v1/Settings/Plex" && method === "GET") return reply(200, store.plex);
if (u.pathname === "/api/v1/Settings/Plex" && method === "POST") {
store.plex = body as Record<string, unknown>;
return reply(200, true);
}
if (u.pathname === "/api/v1/Tester/plex") {
const tried = body as { plexAuthToken?: string; ip?: string };
return reply(200, tried.plexAuthToken === TOKEN && tried.ip === "ace.internal");
}
return reply(404);
},
};
return { http, calls, store };
}
// What an operator's ombi holds: plex loaded through its public name, plus a friend's server.
const operatorPlex = () => ({
enable: true,
enableWatchlistImport: true,
monitorAll: false,
installId: "b358a2a2-2ab0-4025-a3f3-450313c3c418",
servers: [
{
name: "ace", plexAuthToken: TOKEN, machineIdentifier: MACHINE, episodeBatchSize: 150,
serverHostname: "https://app.plex.tv", plexSelectedLibraries: [{ key: "1", title: "Films", enabled: true }],
ssl: true, subDir: null, ip: "plex.zurag.be", port: 443, id: 1,
},
{
name: "a friend", plexAuthToken: "their-token", machineIdentifier: "another-server", episodeBatchSize: 150,
plexSelectedLibraries: [], ssl: false, subDir: null, ip: "10.0.0.9", port: 32400, id: 2,
},
],
id: 4,
});
test("the server's own entry gets the bound connection; its libraries and every other setting stay", async () => {
const f = fakes(operatorPlex());
const out = await reconcilePlex(f.http, OMBI, binding(), `${TOKEN}\n`);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["ip", "port", "ssl"] });
const want = operatorPlex();
Object.assign(want.servers[0], { ip: "ace.internal", port: 32400, ssl: false });
assert.deepEqual(f.store.plex, want);
});
test("another server's entry is never touched", async () => {
const f = fakes(operatorPlex());
await reconcilePlex(f.http, OMBI, binding(), TOKEN);
const servers = f.store.plex.servers as Record<string, unknown>[];
assert.deepEqual(servers[1], operatorPlex().servers[1]);
});
test("nothing is written when ombi already says what the mesh says", async () => {
const doc = operatorPlex();
Object.assign(doc.servers[0], { ip: "ace.internal", port: 32400, ssl: false });
const f = fakes(doc);
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
});
test("an ombi with no entry for this server gets one, named as plex names itself", async () => {
const fresh = { enable: false, enableWatchlistImport: false, monitorAll: false, installId: "x", servers: null, id: 0 };
const f = fakes(fresh);
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["server"] });
assert.deepEqual(f.store.plex, {
...fresh,
servers: [{
name: "ace", machineIdentifier: MACHINE, ip: "ace.internal", port: 32400, ssl: false, subDir: null,
plexAuthToken: TOKEN, episodeBatchSize: 150, plexSelectedLibraries: [],
}],
});
assert.equal(f.store.plex.enable, false, "whether plex is enabled in ombi is the operator's choice");
});
test("a token plex refuses is never written, and the accept that fixes it is named", async () => {
for (const trusted of [false, true]) {
const f = fakes(operatorPlex(), { trusted });
const out = await reconcilePlex(f.http, OMBI, binding(), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
const problem = (out as { problem: string }).problem;
assert.match(problem, /secret accept <this node> ombi plex-api --provider ace/);
assert.doesNotMatch(problem, /a-value-the-mesh-minted/);
assert.deepEqual(f.store.plex, operatorPlex(), "ombi's working settings were left alone");
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
}
});
test("a plex it cannot reach is reported, and ombi is left alone", async () => {
const f = fakes(operatorPlex(), { reachable: false });
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
assert.deepEqual(f.store.plex, operatorPlex());
});
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
const w = wantedPlex(binding(32400, "127.0.0.1"), TOKEN);
assert.equal(w.ok, false);
assert.match((w as { problem: string }).problem, /private network/);
});
test("an https binding sets ombi's ssl flag; an empty subDir is none", () => {
const w = wantedPlex(binding(32400, "ace.internal", "https"), TOKEN);
assert.equal(w.ok && w.connection.ssl, true);
assert.deepEqual(
differingPlex({ ip: "h", port: 1, ssl: false, subDir: "", plexAuthToken: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, plexAuthToken: "k" }),
[],
);
});
test("every entry naming the server is laid over, not only the first", () => {
const doc = { servers: [{ machineIdentifier: MACHINE, ip: "a" }, { machineIdentifier: MACHINE, ip: "b" }] };
const want = { ip: "ace.internal", port: 32400, ssl: false, subDir: null, plexAuthToken: TOKEN };
const laid = withPlexServer(doc, MACHINE, want, "ace");
assert.equal(laid.added, false);
assert.deepEqual((laid.next.servers as { ip: string }[]).map((s) => s.ip), ["ace.internal", "ace.internal"]);
});
// ace's own ombi: its one entry was loaded from an older server (a stale identifier) and retyped to
// plex's public name, so it IS this server, reached another way (read from ace, 2026-09-30).
const acesOmbi = () => ({
enable: true,
enableWatchlistImport: true,
servers: [{
name: "Nami", plexAuthToken: TOKEN, machineIdentifier: "76562198623e708eef85b46aedb72c8f2fe671aa", episodeBatchSize: 0,
plexSelectedLibraries: [1, 2, 3, 4, 5, 6].map((k) => ({ key: String(k), enabled: true })), ssl: true, subDir: null,
ip: "plex.zurag.be", port: 443, id: 1,
}],
id: 4,
});
test("an entry whose own address answers as this server is adopted: connection and identifier, nothing else", async () => {
const f = fakes(acesOmbi());
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["ip", "port", "ssl", "machineIdentifier"] });
const want = acesOmbi();
Object.assign(want.servers[0], { ip: "ace.internal", port: 32400, ssl: false, machineIdentifier: MACHINE });
assert.deepEqual(f.store.plex, want, "one entry, still named Nami, its six libraries kept; none added");
});
test("an entry answering as another server, or not at all, is not adopted; this server gets its own", async () => {
const doc = {
servers: [
{ name: "a friend", machineIdentifier: "stale-1", ip: "10.0.0.9", port: 32400, ssl: false, plexAuthToken: "theirs" },
{ name: "gone", machineIdentifier: "stale-2", ip: "gone.example", port: 32400, ssl: false, plexAuthToken: "old" },
],
};
const f = fakes(structuredClone(doc));
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["server"] });
const servers = f.store.plex.servers as Record<string, unknown>[];
assert.deepEqual(servers.slice(0, 2), doc.servers, "both left exactly as they were");
assert.equal(servers[2].machineIdentifier, MACHINE);
});
test("no entry is probed once one carries the server's identifier", async () => {
const f = fakes(operatorPlex());
await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.equal(f.calls.some((c) => c.url.startsWith("http://10.0.0.9")), false, "the friend's server was not asked");
});