// 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): 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: "other_settings", 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 })[]>; /** What Home Assistant's own connection test accepts. */ accepts?: (data: Record) => boolean; reauth?: FlowProgress[]; devices?: DeviceEntry[]; } function fakeHass(opts: FakeOpts = {}) { const entries = opts.entries ?? {}; const calls: string[] = []; const submitted: Record[] = []; const flows = new Map(); let n = 0; const accepts = opts.accepts ?? (() => true); const form = (id: string, step: string, schema: SchemaField[], errors?: Record): 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 } { const store = new Map(); 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, other_settings: { 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): 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 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, other_settings: { 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")); });