Home Assistant reached mosquitto and the three Servarr apps at 127.0.0.1 and a port typed into its own storage. It now requires mqtt-topic (asking for every topic: discovery and the devices' topics are its job), sonarr-api, radarr-api and lidarr-api, and a run-once `provisions` step — declared last, restarted when a binding or pair credential changes — makes Home Assistant's config entries say what the mesh bound, through Home Assistant's own config flows and never its .storage: - MQTT: the broker is asked first whether it takes the delivered login; then the integration's reconfigure flow sets broker, port, username and password, every other setting sent back as Home Assistant pre-filled it, and Home Assistant's own connection test must pass. A digest of what was written makes a rerun "already as the mesh says". Refused anywhere, nothing is written and Home Assistant keeps the login it has. - Sonarr/Radarr/Lidarr: the bound key is tried against the app (a minted key is never written; the failure names the `secret accept`); no entry is made through the user flow; a reauth Home Assistant started is finished with the bound key (and URL where the integration asks); an entry already at the bound URL, or at another URL reaching the same running app, is left as it is. These integrations have no reconfigure flow, so a working entry elsewhere is refused loudly — the step never removes an entry. The sidecar's URL now uses the port it was given.
294 lines
16 KiB
TypeScript
294 lines
16 KiB
TypeScript
// What holds home-assistant's provisions step (provisions/*.ts): Home Assistant's MQTT entry is
|
|
// made to use the broker, port and login the mesh bound — only after the broker takes that login,
|
|
// through the reconfigure flow, keeping every other setting as Home Assistant pre-filled it, and not
|
|
// again once it already says so; its Sonarr/Radarr/Lidarr entries are made, finished (reauth), left
|
|
// alone when they already reach the bound app, and never removed; a key the app refuses (the mesh's
|
|
// minted value before the operator accepts the app's) is never written.
|
|
//
|
|
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
|
|
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { ConfigEntry, DeviceEntry, FlowProgress, FlowResult, Hass, SchemaField } from "../provisions/hass.ts";
|
|
|
|
import type { Binding, Marks } from "../provisions/connections.ts";
|
|
import { APPS, formValues, reconcileApp, reconcileMqtt, sameUrl, type Http, type ServarrApp } from "../provisions/connections.ts";
|
|
import type { Probe } from "../provisions/probe.ts";
|
|
|
|
const PWD_NOT_CHANGED = "__**password_not_changed**__";
|
|
const MINTED = "mesh-minted-password";
|
|
|
|
function mqttBinding(): Binding {
|
|
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
|
|
}
|
|
|
|
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
|
|
function brokerForm(data: Record<string, unknown>): SchemaField[] {
|
|
return [
|
|
{ name: "broker", type: "string", required: true, description: { suggested_value: data.broker } },
|
|
{ name: "port", type: "integer", required: true, default: 1883, description: { suggested_value: data.port } },
|
|
{ name: "protocol", type: "select", required: true, default: "3.1.1", description: { suggested_value: data.protocol } },
|
|
{ name: "username", type: "string", optional: true, description: { suggested_value: data.username } },
|
|
{ name: "password", type: "string", optional: true, description: { suggested_value: data.password ? PWD_NOT_CHANGED : undefined } },
|
|
{
|
|
name: "advanced_options",
|
|
type: "expandable",
|
|
required: true,
|
|
schema: [
|
|
{ name: "keepalive", type: "integer", optional: true, description: { suggested_value: 60 } },
|
|
{ name: "transport", type: "select", required: true, default: "tcp", description: { suggested_value: "tcp" } },
|
|
{ name: "set_ca_cert", type: "select", required: true, description: { suggested_value: "off" } },
|
|
{ name: "set_client_cert", type: "boolean", required: true, description: { suggested_value: false } },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
interface FakeOpts {
|
|
entries?: Record<string, (ConfigEntry & { data: Record<string, unknown> })[]>;
|
|
/** What Home Assistant's own connection test accepts. */
|
|
accepts?: (data: Record<string, unknown>) => boolean;
|
|
reauth?: FlowProgress[];
|
|
devices?: DeviceEntry[];
|
|
}
|
|
|
|
function fakeHass(opts: FakeOpts = {}) {
|
|
const entries = opts.entries ?? {};
|
|
const calls: string[] = [];
|
|
const submitted: Record<string, unknown>[] = [];
|
|
const flows = new Map<string, { handler: string; entryId?: string; step: string; reauth?: boolean }>();
|
|
let n = 0;
|
|
const accepts = opts.accepts ?? (() => true);
|
|
const form = (id: string, step: string, schema: SchemaField[], errors?: Record<string, string>): FlowResult => ({
|
|
type: "form", flow_id: id, step_id: step, data_schema: schema, errors: errors ?? null,
|
|
});
|
|
const servarrUser: SchemaField[] = [
|
|
{ name: "url", type: "string", required: true },
|
|
{ name: "api_key", type: "string", required: true },
|
|
{ name: "more_options", type: "expandable", required: true, schema: [{ name: "verify_ssl", type: "boolean", optional: true, default: false }] },
|
|
];
|
|
const hass: Hass = {
|
|
async entries(domain) {
|
|
calls.push(`entries ${domain}`);
|
|
return (entries[domain] ?? []).map(({ data: _d, ...e }) => e);
|
|
},
|
|
async startFlow(handler, entryId) {
|
|
calls.push(`start ${handler}${entryId ? ` ${entryId}` : ""}`);
|
|
const id = `f${++n}`;
|
|
if (handler === "mqtt") {
|
|
const entry = entryId ? entries.mqtt.find((e) => e.entry_id === entryId) : undefined;
|
|
if (entryId && !entry) return { type: "abort", reason: "not_found" };
|
|
flows.set(id, { handler, entryId, step: "broker" });
|
|
return form(id, "broker", brokerForm(entry?.data ?? {}));
|
|
}
|
|
if (entryId) return { type: "abort", reason: "not_implemented" }; // no reconfigure for Servarr
|
|
flows.set(id, { handler, step: "user" });
|
|
return form(id, "user", servarrUser);
|
|
},
|
|
async stepFlow(flowId, input) {
|
|
calls.push(`step ${flowId}`);
|
|
const flow = flows.get(flowId);
|
|
if (!flow) throw new Error(`Home Assistant POST flow/${flowId} answered 404`);
|
|
if (flow.step === "reauth_confirm") {
|
|
flow.step = "user";
|
|
return form(flowId, "user", [
|
|
{ name: "url", type: "string", required: true, default: "http://old:1" },
|
|
{ name: "api_key", type: "string", optional: true },
|
|
{ name: "verify_ssl", type: "boolean", optional: true, default: false },
|
|
]);
|
|
}
|
|
submitted.push(input);
|
|
if (flow.handler === "mqtt") {
|
|
const entry = entries.mqtt?.find((e) => e.entry_id === flow.entryId);
|
|
const data = { ...input, ...(input.password === PWD_NOT_CHANGED ? { password: entry?.data.password } : {}) };
|
|
if (!accepts(data)) return form(flowId, "broker", brokerForm(data), { base: "cannot_connect" });
|
|
flows.delete(flowId);
|
|
if (entry) {
|
|
entry.data = data;
|
|
return { type: "abort", reason: "reconfigure_successful" };
|
|
}
|
|
(entries.mqtt ??= []).push({ entry_id: "new-mqtt", domain: "mqtt", state: "loaded", data });
|
|
return { type: "create_entry", result: { entry_id: "new-mqtt" } };
|
|
}
|
|
if (!accepts(input)) return form(flowId, "user", servarrUser, { base: "invalid_auth" });
|
|
flows.delete(flowId);
|
|
if (flow.reauth) return { type: "abort", reason: "reauth_successful" };
|
|
(entries[flow.handler] ??= []).push({ entry_id: `new-${flow.handler}`, domain: flow.handler, state: "loaded", data: input });
|
|
return { type: "create_entry", result: { entry_id: `new-${flow.handler}` } };
|
|
},
|
|
async abortFlow(flowId) {
|
|
calls.push(`abort ${flowId}`);
|
|
flows.delete(flowId);
|
|
},
|
|
async flowsInProgress() {
|
|
for (const f of opts.reauth ?? []) flows.set(f.flow_id, { handler: f.handler, entryId: f.context?.entry_id, step: "reauth_confirm", reauth: true });
|
|
return opts.reauth ?? [];
|
|
},
|
|
async devices() {
|
|
return opts.devices ?? [];
|
|
},
|
|
};
|
|
return { hass, calls, submitted, entries };
|
|
}
|
|
|
|
function memoryMarks(): Marks & { store: Map<string, string> } {
|
|
const store = new Map<string, string>();
|
|
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
|
|
}
|
|
|
|
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
|
|
|
|
const aceMqttEntry = () => ({
|
|
entry_id: "7d1e", domain: "mqtt", state: "loaded",
|
|
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
|
|
});
|
|
|
|
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
|
|
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
|
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
|
|
assert.deepEqual(f.calls, []); // Home Assistant not even asked
|
|
assert.equal(f.entries.mqtt[0].data.username, "luffy");
|
|
});
|
|
|
|
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
|
|
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
|
const marks = memoryMarks();
|
|
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
|
|
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
|
|
assert.deepEqual(f.entries.mqtt[0].data, {
|
|
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
|
|
advanced_options: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
|
});
|
|
assert.ok(marks.store.get("mqtt"));
|
|
assert.ok(![...marks.store.values()].some((v) => v.includes(MINTED)));
|
|
|
|
// Run again: nothing differs, the flow is opened to read and closed without submitting.
|
|
const before = f.submitted.length;
|
|
const again = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
|
assert.deepEqual(again, { what: "mqtt", result: "unchanged" });
|
|
assert.equal(f.submitted.length, before);
|
|
assert.match(f.calls.at(-1) ?? "", /^abort /);
|
|
});
|
|
|
|
test("mqtt: a new password alone is written (the digest tells)", async () => {
|
|
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
|
|
const marks = memoryMarks();
|
|
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
|
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
|
|
const out = await reconcileMqtt({ hass: f.hass, probe: rotated, marks }, mqttBinding(), "rotated");
|
|
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["password"] });
|
|
assert.equal(f.entries.mqtt[0].data.password, "rotated");
|
|
});
|
|
|
|
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
|
|
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
|
|
const marks = memoryMarks();
|
|
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /cannot_connect.*unchanged/);
|
|
assert.equal(f.entries.mqtt[0].data.username, "luffy");
|
|
assert.equal(marks.store.size, 0);
|
|
});
|
|
|
|
test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery topics is warned about", async () => {
|
|
const f = fakeHass();
|
|
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
|
|
assert.equal(out.result, "written");
|
|
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
|
|
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
|
|
});
|
|
|
|
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
|
|
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
|
|
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
|
|
const noPort = { ...mqttBinding(), serves: {} };
|
|
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
|
|
});
|
|
|
|
// ---- Servarr ----
|
|
|
|
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
|
|
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
|
|
const KEY = "the-apps-own-key";
|
|
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
|
|
|
|
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
|
|
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
|
|
const asked: string[] = [];
|
|
return {
|
|
asked,
|
|
async fetch(url, init) {
|
|
asked.push(url);
|
|
const u = new URL(url);
|
|
const inst = instances[`${u.hostname}:${u.port}`];
|
|
if (!inst) throw new Error("connect ECONNREFUSED");
|
|
if (init?.headers?.["X-Api-Key"] !== KEY) return { status: 401, text: async () => "" };
|
|
return { status: 200, text: async () => JSON.stringify({ version: "4.0.15", appData: "/config", startTime: inst.startTime }) };
|
|
},
|
|
};
|
|
}
|
|
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
|
|
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
|
|
|
|
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
|
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
|
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
|
|
assert.deepEqual(f.calls, []);
|
|
});
|
|
|
|
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
|
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
|
assert.equal(out.result, "equivalent");
|
|
assert.equal(f.submitted.length, 0);
|
|
});
|
|
|
|
test("servarr: an entry already at the bound URL is unchanged", async () => {
|
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
|
|
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
|
|
});
|
|
|
|
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
|
|
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
|
|
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
|
|
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
|
|
assert.equal(out.result, "refused");
|
|
assert.match((out as { problem: string }).problem, /never does/);
|
|
assert.equal(f.entries.sonarr.length, 1);
|
|
});
|
|
|
|
test("servarr: no entry — one is made at the bound URL through the user flow", async () => {
|
|
const f = fakeHass({ entries: {} });
|
|
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
|
|
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
|
|
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
|
|
});
|
|
|
|
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
|
|
const entry = { ...sonarrEntry("setup_error"), domain: "radarr", entry_id: "1955" };
|
|
const f = fakeHass({
|
|
entries: { radarr: [entry] },
|
|
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
|
|
});
|
|
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
|
|
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
|
|
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
|
|
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
|
|
});
|
|
|
|
test("form values: suggested first, then default, sections nested", () => {
|
|
assert.deepEqual(formValues(brokerForm({ broker: "b", port: 1, protocol: "5", username: "u", password: "p" })), {
|
|
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
|
|
advanced_options: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
|
|
});
|
|
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
|
|
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
|
|
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
|
|
});
|