diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 5b1ff93..f247720 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -75,6 +75,45 @@ Write down what you found before you write the configuration. The state paths yo choose become a disclosure boundary and a public interface at the same time, and renaming one after the household has used it is not free. +### Reading a V3 house you inherited + +Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes. Ask +the owner for the `flows.json` Node-RED writes, or for an *Export > All flows* +download, and read it before you read anything else: + +``` +miakapp discover --flows ~/node-red/flows.json +miakapp discover --flows ~/node-red/flows.json --json +``` + +The command is offline and read-only: it opens no socket, contacts no broker and +never writes back into the export. It reports the tabs, the MQTT brokers with the +topics their nodes actually reach, the `initMiakapi` home bindings, every +`commitVariables` path as a state candidate, every `onUserAction` id as a +function candidate with the groups allowed to invoke it, and every node type it +does not model — so you know what the inventory missed rather than assuming it +missed nothing. + +Four of its findings decide work you would otherwise discover late: + +- **`secret_in_export`.** The v3 `initMiakapi` node declares `coordSecret` in its + `defaults`, not in its `credentials`, so Node-RED stores that secret in + cleartext in `flows.json` rather than in the encrypted `flows_cred.json`. If + the export has one, treat it as leaked: rotate it, and keep the file out of + Git. §9 is the V4 rule that replaces it. +- **`unrestricted_action`.** The v3 handler allows an action outright when its + node lists no group, so an empty `allowedGroups` is a grant to every signed-in + user, not a deny. Each one needs a deliberate V4 rule before you port it. +- **`name_needs_rename`.** A v3 variable path or action id that is not a legal V4 + dotted name has to be renamed now, while nobody depends on it. +- **`wildcard_subscription`.** A topic holding `#` or `+` is a subscription + pattern, not one device. Enumerate what it actually matches. + +The command deliberately does not tell you which actions are physically +consequential. It lists every action it found; deciding which of them heats, +locks, unlocks, opens or closes is a judgement you make with the owner, and no +keyword list should make it for you. + ## 4. The coordinator `templates/home/coordinator/home.ts` is the shape to copy: the configuration is a diff --git a/packages/cli/README.md b/packages/cli/README.md index aefa23e..b96c1fc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | Command | What it does | | --- | --- | | `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `discover` | Offline. Inventories a Node-RED installation from its flows export. | | `check` | Offline. Parses the project, verifies the artifact, prints the digest. | | `publish` | Capability → delivery → finalization → activation, in one run. | | `activate` | Activates an already finalized digest at a new generation. | @@ -68,6 +69,20 @@ nothing, touches no network and catches the four artifact rules the broker's pinned parser would reject anyway: module syntax, dynamic `import`, a source-map directive and the ABI 1 token ceiling. +`discover` is the command to run *before* `init`, on a house that already exists: + +``` +miakapp discover --flows ~/node-red/flows.json --json +``` + +It needs no project file and no Home Key. It reads the bytes it was given — +opening no socket, contacting no broker, writing nothing back — and reports the +flows, the MQTT brokers with the topics their nodes actually reach, the v3 +MiakAPI surface as V4 state and function candidates, and every node type it does +not model, so the reader knows what the inventory missed. It reports that a +coordinator secret is present in the export; it never prints the secret itself. +`docs/agent-guide.md` §3 explains what to do with each finding. + ## Authorization The Home Key is read from `MIAKAPP_HOME_KEY` and from nowhere else. No command diff --git a/packages/cli/src/discovery.ts b/packages/cli/src/discovery.ts new file mode 100644 index 0000000..6b3c5ed --- /dev/null +++ b/packages/cli/src/discovery.ts @@ -0,0 +1,532 @@ +/** + * Reading an installation that already exists. + * + * `docs/agent-guide.md` §3 tells an agent to characterize the house before + * designing anything. This module is the part of that work a program can do: + * it turns a Node-RED `flows.json` export into an inventory of brokers, flows, + * topics and the V3 MiakAPI surface, and it reports which V3 names are already + * legal V4 names. + * + * Three properties keep it honest: + * + * - **Offline and read-only.** It parses bytes handed to it. It opens no + * socket, contacts no broker and writes nothing back into the export. + * - **It never drops a node silently.** Every unrecognized `type` is counted + * and reported, because the value of an inventory is knowing what it missed. + * - **It never guesses semantics.** It reports what a node declares. Which + * actions are physically consequential is a judgement the reader makes from + * the listed surface; no keyword list decides it here. + * + * Field names come from the two schemas involved: Node-RED core `mqtt in`, + * `mqtt out` and `mqtt-broker` node definitions, and the `node-red-contrib- + * MiakAPI` v3 node definitions in `miakapi.html`. + */ +import { projectError } from './errors.js'; +import { isDottedName, utf8Bytes } from './internal/names.js'; + +/** + * A generous ceiling for a local export. The strict parser in `internal/json.ts` + * is bounded for untrusted control-plane responses at 2,048 values, which a real + * house blows through in the first tab; a flows export is an operator-supplied + * local file, so the bound here is on bytes rather than on structure. + */ +export const MAXIMUM_FLOWS_BYTES = 33_554_432; + +/** Keys that would poison a prototype if a record were ever spread. */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +const MIAKAPI_V3_TYPES = new Set([ + 'initMiakapi', + 'getHomeUsers', + 'commitVariables', + 'onHomeReady', + 'onHomeUpdate', + 'onUserLogin', + 'onUserAction', + 'sendPushNotif', + 'reconnectMiakapi', +]); + +export type FindingKind = + /** A coordinator secret sits in cleartext in the export. */ + | 'secret_in_export' + /** An action any signed-in user may invoke, because no group was listed. */ + | 'unrestricted_action' + /** A V3 name that is not a legal V4 dotted name and has to be renamed. */ + | 'name_needs_rename' + /** A subscription pattern rather than one device's topic. */ + | 'wildcard_subscription' + /** A broker reached without TLS. */ + | 'broker_without_tls' + /** A node type this inventory does not model. */ + | 'unmodelled_node'; + +export type FindingSeverity = 'critical' | 'attention' | 'note'; + +export interface Finding { + readonly kind: FindingKind; + readonly severity: FindingSeverity; + readonly detail: string; + readonly nodeId: string | undefined; +} + +export interface Broker { + readonly id: string; + readonly name: string; + readonly host: string; + readonly port: number | undefined; + readonly tls: boolean; + readonly subscribes: readonly string[]; + readonly publishes: readonly string[]; +} + +export interface FlowTab { + readonly id: string; + readonly label: string; + readonly disabled: boolean; + readonly nodeCount: number; +} + +export interface HomeBinding { + readonly nodeId: string; + readonly homeId: string; + readonly coordinatorId: string; + readonly secretInExport: boolean; +} + +/** One `commitVariables` entry: a V3 variable path and where its value came from. */ +export interface StateCandidate { + readonly path: string; + readonly source: 'jsonata' | 'env' | 'literal'; + readonly nodeId: string; + readonly legalV4Name: boolean; +} + +/** One `onUserAction` handler: the V3 shape of what becomes a V4 function. */ +export interface ActionCandidate { + readonly inputId: string; + readonly allowedGroups: readonly string[]; + readonly nodeId: string; + readonly legalV4Name: boolean; +} + +/** One `sendPushNotif` node: the V3 shape of what becomes a V4 published event. */ +export interface NotificationCandidate { + readonly nodeId: string; + readonly name: string; + readonly adminOnly: boolean; + readonly group: string; +} + +export interface Inventory { + readonly nodeCount: number; + readonly flows: readonly FlowTab[]; + readonly brokers: readonly Broker[]; + readonly homes: readonly HomeBinding[]; + readonly state: readonly StateCandidate[]; + readonly actions: readonly ActionCandidate[]; + readonly notifications: readonly NotificationCandidate[]; + /** Every type this module does not model, with how many nodes carry it. */ + readonly unmodelled: readonly { readonly type: string; readonly count: number }[]; + readonly findings: readonly Finding[]; +} + +type Record_ = Readonly>; + +function field(node: Record_, key: string): unknown { + return Object.hasOwn(node, key) ? node[key] : undefined; +} + +function text(node: Record_, key: string): string { + const value = field(node, key); + return typeof value === 'string' ? value : ''; +} + +function flag(node: Record_, key: string): boolean { + return field(node, key) === true; +} + +/** Node-RED writes `port` as either a number or a numeric string. */ +function port(node: Record_): number | undefined { + const value = field(node, 'port'); + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string' && /^[0-9]{1,5}$/.test(value)) return Number(value); + return undefined; +} + +function finding( + kind: FindingKind, + severity: FindingSeverity, + detail: string, + nodeId?: string, +): Finding { + return Object.freeze({ kind, severity, detail, nodeId }); +} + +/** + * Parses the export. + * + * A flows export is a flat array of node records; tabs, config nodes and wired + * nodes all sit at the same level and refer to each other by `id`. + */ +function parseFlows(source: Uint8Array): readonly Record_[] { + if (source.byteLength > MAXIMUM_FLOWS_BYTES) { + throw projectError( + `The flows export is larger than ${MAXIMUM_FLOWS_BYTES} bytes`, + 'Export one Node-RED instance at a time.', + ); + } + let decoded: string; + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(source); + } catch { + throw projectError('The flows export is not readable as UTF-8 text'); + } + let parsed: unknown; + try { + parsed = JSON.parse(decoded) as unknown; + } catch { + throw projectError( + 'The flows export is not valid JSON', + 'Use the file Node-RED writes, or the Export > All flows download, not a screenshot of it.', + ); + } + if (!Array.isArray(parsed)) { + throw projectError( + 'The flows export is not a JSON array of nodes', + 'A Node-RED export is a flat array; an object here is usually a single copied node.', + ); + } + const nodes: Record_[] = []; + for (const entry of parsed) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue; + // Values are only ever read through `Object.hasOwn`, never spread, so a + // poisoned key cannot reach a prototype; it is dropped here regardless. + if (Reflect.ownKeys(entry).some((key) => FORBIDDEN_KEYS.has(String(key)))) continue; + if (typeof (entry as Record_)['type'] !== 'string') continue; + nodes.push(entry as Record_); + } + return nodes; +} + +function collectTabs(nodes: readonly Record_[]): readonly FlowTab[] { + const counts = new Map(); + for (const node of nodes) { + const parent = text(node, 'z'); + if (parent !== '') counts.set(parent, (counts.get(parent) ?? 0) + 1); + } + return nodes + .filter((node) => node['type'] === 'tab') + .map((node) => { + const id = text(node, 'id'); + return Object.freeze({ + id, + label: text(node, 'label'), + disabled: flag(node, 'disabled'), + nodeCount: counts.get(id) ?? 0, + }); + }); +} + +function collectBrokers(nodes: readonly Record_[], findings: Finding[]): readonly Broker[] { + const subscribes = new Map>(); + const publishes = new Map>(); + + for (const node of nodes) { + const type = node['type']; + if (type !== 'mqtt in' && type !== 'mqtt out') continue; + const broker = text(node, 'broker'); + const topic = text(node, 'topic'); + if (broker === '') continue; + if (topic === '') { + // `mqtt in` with `topicType: dynamic` takes its topic from a message, so + // the export cannot say which devices it will reach. + findings.push(finding( + 'unmodelled_node', + 'attention', + `${String(type)} node has no static topic; its subscription is set at runtime`, + text(node, 'id'), + )); + continue; + } + const into = type === 'mqtt in' ? subscribes : publishes; + const set = into.get(broker) ?? new Set(); + set.add(topic); + into.set(broker, set); + if (type === 'mqtt in' && (topic.includes('#') || topic.includes('+'))) { + findings.push(finding( + 'wildcard_subscription', + 'note', + `Subscription ${topic} is a pattern, not one device; enumerate what it actually matches`, + text(node, 'id'), + )); + } + } + + return nodes + .filter((node) => node['type'] === 'mqtt-broker') + .map((node) => { + const id = text(node, 'id'); + const host = text(node, 'broker'); + const tls = flag(node, 'usetls'); + if (!tls) { + findings.push(finding( + 'broker_without_tls', + 'attention', + `Broker ${host === '' ? id : host} is configured without TLS`, + id, + )); + } + return Object.freeze({ + id, + name: text(node, 'name'), + host, + port: port(node), + tls, + subscribes: [...subscribes.get(id) ?? []].sort(), + publishes: [...publishes.get(id) ?? []].sort(), + }); + }); +} + +function collectHomes(nodes: readonly Record_[], findings: Finding[]): readonly HomeBinding[] { + return nodes + .filter((node) => node['type'] === 'initMiakapi') + .map((node) => { + const nodeId = text(node, 'id'); + // `coordSecret` is declared in the node's `defaults`, not in its + // `credentials`, so Node-RED stores it in `flows.json` itself rather than + // in the encrypted `flows_cred.json`. + const secretInExport = text(node, 'coordSecret') !== ''; + if (secretInExport) { + findings.push(finding( + 'secret_in_export', + 'critical', + 'A coordinator secret is stored in cleartext in this export; treat it as leaked, ' + + 'rotate it, and keep the export out of Git', + nodeId, + )); + } + return Object.freeze({ + nodeId, + homeId: text(node, 'home'), + coordinatorId: text(node, 'coordID'), + secretInExport, + }); + }); +} + +/** + * Why a V3 name is not a legal V4 dotted name, in the words of the rule it + * breaks. `isDottedName` answers yes or no; a reader who has to rename a path + * needs to know which constraint bit them. + */ +function illegalNameReason(value: string): string { + if (value === '') return 'it is empty'; + if (value.includes('*')) return 'it contains *, which V4 reserves for the trailing .* suffix'; + if (/\p{Cc}/u.test(value)) return 'it contains a control character'; + if (value.split('.').some((segment) => segment === '')) { + return 'it has an empty dotted segment'; + } + return `it is ${utf8Bytes(value)} UTF-8 bytes, outside the 1..256 range`; +} + +function variableSource(type: unknown): 'jsonata' | 'env' | 'literal' { + if (type === 'jsonata') return 'jsonata'; + if (type === 'env') return 'env'; + return 'literal'; +} + +function collectState(nodes: readonly Record_[], findings: Finding[]): readonly StateCandidate[] { + const candidates: StateCandidate[] = []; + for (const node of nodes) { + if (node['type'] !== 'commitVariables') continue; + const values = field(node, 'values'); + if (values === null || typeof values !== 'object' || Array.isArray(values)) continue; + const nodeId = text(node, 'id'); + for (const path of Object.keys(values)) { + if (FORBIDDEN_KEYS.has(path)) continue; + const entry = (values as Record_)[path]; + const type = entry !== null && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as Record_)['type'] + : undefined; + const legalV4Name = isDottedName(path); + if (!legalV4Name) { + findings.push(finding( + 'name_needs_rename', + 'attention', + `Variable path ${path} is not a legal V4 state path: ${illegalNameReason(path)}; ` + + 'rename it before the household depends on it', + nodeId, + )); + } + candidates.push(Object.freeze({ + path, + source: variableSource(type), + nodeId, + legalV4Name, + })); + } + } + return candidates.sort((left, right) => (left.path < right.path ? -1 : 1)); +} + +function collectActions(nodes: readonly Record_[], findings: Finding[]): readonly ActionCandidate[] { + const candidates: ActionCandidate[] = []; + for (const node of nodes) { + if (node['type'] !== 'onUserAction') continue; + const nodeId = text(node, 'id'); + const inputId = text(node, 'inputID'); + const raw = field(node, 'allowedGroups'); + const allowedGroups = Array.isArray(raw) + ? raw.filter((group): group is string => typeof group === 'string') + : []; + // The v3 handler allows the action outright when no group is listed, so an + // empty list is a grant to every signed-in user, not a deny. + if (allowedGroups.length === 0) { + findings.push(finding( + 'unrestricted_action', + 'critical', + `Action ${inputId === '' ? nodeId : inputId} lists no group, so every signed-in user ` + + 'may invoke it; V4 needs an explicit rule for it', + nodeId, + )); + } + const legalV4Name = isDottedName(inputId); + if (!legalV4Name) { + findings.push(finding( + 'name_needs_rename', + 'attention', + `Action id ${inputId === '' ? '(empty)' : inputId} is not a legal V4 function name: ` + + illegalNameReason(inputId), + nodeId, + )); + } + candidates.push(Object.freeze({ inputId, allowedGroups, nodeId, legalV4Name })); + } + return candidates.sort((left, right) => (left.inputId < right.inputId ? -1 : 1)); +} + +function collectNotifications(nodes: readonly Record_[]): readonly NotificationCandidate[] { + return nodes + .filter((node) => node['type'] === 'sendPushNotif') + .map((node) => Object.freeze({ + nodeId: text(node, 'id'), + name: text(node, 'name'), + adminOnly: flag(node, 'adminOnly'), + group: text(node, 'group'), + })); +} + +function collectUnmodelled( + nodes: readonly Record_[], + findings: Finding[], +): readonly { readonly type: string; readonly count: number }[] { + const modelled = new Set(['tab', 'mqtt in', 'mqtt out', 'mqtt-broker', ...MIAKAPI_V3_TYPES]); + const counts = new Map(); + for (const node of nodes) { + const type = node['type'] as string; + if (modelled.has(type)) continue; + counts.set(type, (counts.get(type) ?? 0) + 1); + } + const unmodelled = [...counts] + .map(([type, count]) => Object.freeze({ type, count })) + .sort((left, right) => right.count - left.count || (left.type < right.type ? -1 : 1)); + if (unmodelled.length > 0) { + findings.push(finding( + 'unmodelled_node', + 'note', + `${unmodelled.length} node type(s) are not modelled by this inventory; ` + + 'read them yourself before assuming the house is fully described', + )); + } + return unmodelled; +} + +/** + * Builds the inventory for one Node-RED export. + * + * The result is a pure function of the bytes: the same export always produces + * the same report, which is what makes it usable as a migration baseline that + * can be diffed between two runs. + */ +export function discoverFlows(source: Uint8Array): Inventory { + const nodes = parseFlows(source); + const findings: Finding[] = []; + const flows = collectTabs(nodes); + const brokers = collectBrokers(nodes, findings); + const homes = collectHomes(nodes, findings); + const state = collectState(nodes, findings); + const actions = collectActions(nodes, findings); + const notifications = collectNotifications(nodes); + const unmodelled = collectUnmodelled(nodes, findings); + const order: Record = { critical: 0, attention: 1, note: 2 }; + return Object.freeze({ + nodeCount: nodes.length, + flows, + brokers, + homes, + state, + actions, + notifications, + unmodelled, + findings: findings.sort((left, right) => order[left.severity] - order[right.severity]), + }); +} + +/** The JSON body of `miakapp discover --json`, with no secret value in it. */ +export function inventoryJson(inventory: Inventory): Record { + return { + node_count: inventory.nodeCount, + flows: inventory.flows.map((tab) => ({ + id: tab.id, + label: tab.label, + disabled: tab.disabled, + node_count: tab.nodeCount, + })), + brokers: inventory.brokers.map((broker) => ({ + id: broker.id, + name: broker.name, + host: broker.host, + ...(broker.port === undefined ? {} : { port: broker.port }), + tls: broker.tls, + subscribes: broker.subscribes, + publishes: broker.publishes, + })), + homes: inventory.homes.map((home) => ({ + node_id: home.nodeId, + home_id: home.homeId, + coordinator_id: home.coordinatorId, + // The flag says a secret is present. The secret itself is never read out. + secret_in_export: home.secretInExport, + })), + state: inventory.state.map((candidate) => ({ + path: candidate.path, + source: candidate.source, + node_id: candidate.nodeId, + legal_v4_name: candidate.legalV4Name, + })), + actions: inventory.actions.map((candidate) => ({ + input_id: candidate.inputId, + allowed_groups: candidate.allowedGroups, + node_id: candidate.nodeId, + legal_v4_name: candidate.legalV4Name, + })), + notifications: inventory.notifications.map((candidate) => ({ + node_id: candidate.nodeId, + name: candidate.name, + admin_only: candidate.adminOnly, + group: candidate.group, + })), + unmodelled: inventory.unmodelled.map((entry) => ({ + type: entry.type, + count: entry.count, + })), + findings: inventory.findings.map((item) => ({ + kind: item.kind, + severity: item.severity, + detail: item.detail, + ...(item.nodeId === undefined || item.nodeId === '' ? {} : { node_id: item.nodeId }), + })), + }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 17b84e8..137806f 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -16,6 +16,7 @@ */ import { prepareArtifact, type Artifact } from './artifact.js'; import { exchangePublisherToken, fetchDiscovery } from './control-plane.js'; +import { discoverFlows, inventoryJson, type Inventory } from './discovery.js'; import { CliError, EXIT_CODE, @@ -84,6 +85,7 @@ Usage Commands init Write ${PROJECT_FILE} in the current directory + discover Inventory an existing Node-RED installation offline check Validate the project and the artifact offline publish Upload, finalize and activate the built artifact activate Activate an already finalized digest at a new generation @@ -107,6 +109,9 @@ activate / rollback options --expected-generation Generation the pointer is expected to hold (required) --generation Generation to publish (default: expected + 1) +discover options + --flows Node-RED flows export to read (required) + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -128,6 +133,7 @@ const GLOBAL_OPTIONS = ['project'] as const; const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], + discover: ['flows'], check: [], publish: ['expected-generation', 'generation', 'release'], activate: ['sha256', 'expected-generation', 'generation'], @@ -390,6 +396,76 @@ async function runInit(host: CliHost, invocation: Invocation): Promise { + const path = requiredOption(invocation, 'flows'); + const filesystem = await files(host); + if (!await filesystem.exists(path)) { + throw projectError( + `No flows export at ${path}`, + 'Point --flows at the Node-RED flows.json, or at an Export > All flows download.', + ); + } + const inventory = discoverFlows(await filesystem.read(path)); + return { + summary: discoverSummary(inventory), + fields: discoverFields(inventory), + json: inventoryJson(inventory), + }; +} + +function counted(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function discoverSummary(inventory: Inventory): string { + const critical = inventory.findings.filter((item) => item.severity === 'critical').length; + const census = [ + counted(inventory.nodeCount, 'node', 'nodes'), + counted(inventory.flows.length, 'flow', 'flows'), + counted(inventory.brokers.length, 'broker', 'brokers'), + counted(inventory.state.length, 'state path', 'state paths'), + counted(inventory.actions.length, 'action', 'actions'), + ].join(', '); + return critical === 0 + ? census + : `${census} — ${counted(critical, 'finding', 'findings')} to settle before migrating`; +} + +function discoverFields(inventory: Inventory): readonly Field[] { + const fields: Field[] = []; + for (const home of inventory.homes) { + fields.push([`home.${home.homeId}`, `coordinator ${home.coordinatorId}`]); + } + for (const broker of inventory.brokers) { + const address = broker.port === undefined ? broker.host : `${broker.host}:${broker.port}`; + fields.push([ + `broker.${broker.name === '' ? broker.id : broker.name}`, + `${address} tls=${broker.tls} in=${broker.subscribes.length} out=${broker.publishes.length}`, + ]); + } + for (const tab of inventory.flows) { + fields.push([`flow.${tab.label === '' ? tab.id : tab.label}`, `${tab.nodeCount} nodes`]); + } + if (inventory.state.length > 0) { + fields.push(['state', inventory.state.map((entry) => entry.path)]); + } + if (inventory.actions.length > 0) { + fields.push(['actions', inventory.actions.map((entry) => entry.inputId)]); + } + if (inventory.unmodelled.length > 0) { + fields.push(['unmodelled', inventory.unmodelled.map((entry) => `${entry.type}×${entry.count}`)]); + } + for (const item of inventory.findings) { + fields.push([item.severity, item.detail]); + } + return fields; +} + async function runCheck(host: CliHost, invocation: Invocation): Promise { const project = await loadProject(host, invocation); const artifact = await loadArtifact(host, project); @@ -530,6 +606,8 @@ async function dispatch(host: CliHost, invocation: Invocation): Promise item.kind); +} + +describe('reading a flows export', () => { + test('an export that is not JSON is a project failure, not a crash', () => { + expect(() => discoverFlows(new TextEncoder().encode('not json'))).toThrow(/not valid JSON/); + }); + + test('a single copied node is rejected with the reason', () => { + expect(() => discoverFlows(new TextEncoder().encode('{"id":"a","type":"tab"}'))) + .toThrow(/not a JSON array/); + }); + + test('an empty export inventories nothing rather than failing', () => { + const empty = inventory('[]'); + expect(empty.nodeCount).toBe(0); + expect(empty.flows).toEqual([]); + expect(empty.findings).toEqual([]); + }); + + test('an export above the byte ceiling is refused before it is parsed', () => { + const oversize = new Uint8Array(MAXIMUM_FLOWS_BYTES + 1); + expect(() => discoverFlows(oversize)).toThrow(/larger than/); + }); + + test('a node without a string type is skipped, not counted', () => { + expect(inventory('[{"id":"a"},{"id":"b","type":7},{"id":"c","type":"tab"}]').nodeCount).toBe(1); + }); + + test('a prototype-polluting key never reaches the inventory', () => { + const poisoned = '[{"id":"a","type":"tab","__proto__":{"polluted":true}}]'; + expect(inventory(poisoned).nodeCount).toBe(0); + expect(({} as Record)['polluted']).toBeUndefined(); + }); +}); + +describe('the inventory of a house', () => { + test('every tab is reported with how many nodes it holds', () => { + const tabs = inventory().flows; + expect(tabs.map((tab) => tab.label)).toEqual(['Salon', 'Chauffage']); + expect(tabs[0]?.nodeCount).toBe(5); + expect(tabs[1]?.disabled).toBe(true); + }); + + test('a broker carries the topics its nodes actually reach', () => { + const broker = inventory().brokers[0]; + expect(broker?.host).toBe('192.168.1.10'); + expect(broker?.port).toBe(1883); + expect(broker?.subscribes).toEqual(['maison/salon/#', 'maison/salon/temperature']); + expect(broker?.publishes).toEqual(['maison/salon/lampe/set']); + }); + + test('the home binding is reported without reading the secret out', () => { + const home = inventory().homes[0]; + expect(home?.homeId).toBe('maison-colmon'); + expect(home?.coordinatorId).toBe('coord-1'); + expect(home?.secretInExport).toBe(true); + expect(JSON.stringify(inventory())).not.toContain('s3cr3t-in-the-file'); + }); + + test('committed variables become state candidates, sorted and name-checked', () => { + const state = inventory().state; + expect(state.map((entry) => entry.path)).toEqual([ + 'chauffage.consigne', + 'salon.*.on', + 'salon.lampe.on', + 'salon.temperature', + 'salon/humidite', + ]); + expect(state.find((entry) => entry.path === 'salon.temperature')?.source).toBe('jsonata'); + expect(state.find((entry) => entry.path === 'chauffage.consigne')?.source).toBe('env'); + expect(state.find((entry) => entry.path === 'salon.lampe.on')?.source).toBe('literal'); + expect(state.find((entry) => entry.path === 'salon/humidite')?.legalV4Name).toBe(true); + }); + + test('user actions become function candidates with their groups', () => { + const actions = inventory().actions; + expect(actions.map((entry) => entry.inputId)).toEqual(['chauffage.set', 'salon.lampe.toggle']); + expect(actions[0]?.allowedGroups).toEqual(['adultes']); + expect(actions[1]?.allowedGroups).toEqual([]); + }); + + test('notifications are reported with the audience they were sent to', () => { + const notification = inventory().notifications[0]; + expect(notification?.adminOnly).toBe(true); + expect(notification?.group).toBe(''); + }); + + test('a node type the inventory does not model is counted, never dropped', () => { + const unmodelled = inventory().unmodelled; + expect(unmodelled).toEqual([ + { type: 'function', count: 2 }, + { type: 'inject', count: 1 }, + ]); + }); +}); + +describe('what the inventory refuses to leave unsaid', () => { + test('a coordinator secret in the export is reported as critical', () => { + const secret = inventory().findings.find((item) => item.kind === 'secret_in_export'); + expect(secret?.severity).toBe('critical'); + expect(secret?.detail).toContain('rotate'); + }); + + test('an action with no group is reported as reachable by every user', () => { + const open = inventory().findings.find((item) => item.kind === 'unrestricted_action'); + expect(open?.severity).toBe('critical'); + expect(open?.detail).toContain('salon.lampe.toggle'); + }); + + test('a V3 name that V4 would reject is reported for rename', () => { + const rename = inventory().findings.filter((item) => item.kind === 'name_needs_rename'); + expect(rename.map((item) => item.detail).join(' ')).toContain('salon.*.on'); + }); + + test('a wildcard subscription is separated from a device topic', () => { + const wildcard = inventory().findings.find((item) => item.kind === 'wildcard_subscription'); + expect(wildcard?.detail).toContain('maison/salon/#'); + }); + + test('a broker without TLS is reported', () => { + expect(findingKinds()).toContain('broker_without_tls'); + }); + + test('critical findings sort ahead of notes', () => { + const severities = inventory().findings.map((item) => item.severity); + expect(severities).toEqual([...severities].sort( + (left, right) => ['critical', 'attention', 'note'].indexOf(left) + - ['critical', 'attention', 'note'].indexOf(right), + )); + }); + + test('a house with nothing wrong reports no finding', () => { + const clean = inventory(JSON.stringify([ + { id: 't1', type: 'tab', label: 'Salon' }, + { id: 'b1', type: 'mqtt-broker', name: 'local', broker: 'mqtt.example.test', port: '8883', usetls: true }, + { id: 'i1', type: 'initMiakapi', z: 't1', home: 'maison', coordID: 'c1', coordSecret: '' }, + { id: 'a1', type: 'onUserAction', z: 't1', inputID: 'salon.lampe.toggle', allowedGroups: ['adultes'] }, + ])); + expect(clean.findings).toEqual([]); + }); +}); + +describe('the discover command', () => { + test('it reports the house without needing a project file', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH], host)).toBe(EXIT_CODE.success); + expect(host.stdout()).toContain('5 state paths'); + expect(host.stdout()).toContain('192.168.1.10:1883'); + }); + + test('--json emits one closed object an agent can branch on', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH, '--json'], host)).toBe(EXIT_CODE.success); + const report = host.json(); + expect(report['ok']).toBe(true); + expect(report['command']).toBe('discover'); + expect(report['node_count']).toBe(15); + expect((report['homes'] as Record[])[0]?.['secret_in_export']).toBe(true); + expect(JSON.stringify(report)).not.toContain('s3cr3t-in-the-file'); + }); + + test('a missing export is a project failure with the path in it', async () => { + const host = testHost({ files: new MemoryFiles() }); + expect(await run(['discover', '--flows', '/tmp/absent.json'], host)).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain('/tmp/absent.json'); + }); + + test('discover without --flows is a usage failure', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover'], host)).toBe(EXIT_CODE.usage); + expect(host.stderr()).toContain('--flows is required'); + }); + + test('discover rejects a publication option', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH, '--generation', '2'], host)) + .toBe(EXIT_CODE.usage); + }); +}); diff --git a/packages/cli/test/support/flows.ts b/packages/cli/test/support/flows.ts new file mode 100644 index 0000000..552094d --- /dev/null +++ b/packages/cli/test/support/flows.ts @@ -0,0 +1,74 @@ +import { MemoryFiles } from './host.js'; + +export const FLOWS_PATH = '/home/mathieu/node-red/flows.json'; + +/** + * A synthetic V3 house, written to exercise every branch of the inventory: two + * tabs of which one is disabled, one broker without TLS, a device topic beside a + * wildcard subscription, a coordinator secret sitting in the export, variables + * committed from all three value types, an action restricted to a group beside + * one restricted to nobody, and node types the inventory does not model. + * + * Field names follow the two real schemas: Node-RED core `mqtt in`, `mqtt out` + * and `mqtt-broker`, and the `node-red-contrib-MiakAPI` v3 nodes. + */ +export const FLOWS_EXPORT = JSON.stringify([ + { id: 't1', type: 'tab', label: 'Salon' }, + { id: 't2', type: 'tab', label: 'Chauffage', disabled: true }, + { + id: 'b1', + type: 'mqtt-broker', + name: 'maison', + broker: '192.168.1.10', + port: '1883', + usetls: false, + cleansession: true, + }, + { + id: 'i1', + type: 'initMiakapi', + z: 't1', + home: 'maison-colmon', + coordID: 'coord-1', + coordSecret: 's3cr3t-in-the-file', + }, + { id: 'm1', type: 'mqtt in', z: 't1', broker: 'b1', topic: 'maison/salon/temperature', qos: '2' }, + { id: 'm2', type: 'mqtt in', z: 't1', broker: 'b1', topic: 'maison/salon/#', qos: '0' }, + { id: 'm3', type: 'mqtt out', z: 't1', broker: 'b1', topic: 'maison/salon/lampe/set', retain: '' }, + { + id: 'cv1', + type: 'commitVariables', + z: 't1', + name: 'Salon', + values: { + 'salon.temperature': { type: 'jsonata', value: 'payload.temp' }, + 'salon.lampe.on': { type: 'str', value: 'false' }, + 'salon/humidite': { type: 'str', value: '0' }, + }, + }, + { id: 'a1', type: 'onUserAction', z: 't2', inputID: 'salon.lampe.toggle', allowedGroups: [] }, + { + id: 'a2', + type: 'onUserAction', + z: 't2', + inputID: 'chauffage.set', + allowedGroups: ['adultes'], + }, + { + id: 'cv2', + type: 'commitVariables', + z: 't2', + values: { + 'chauffage.consigne': { type: 'env', value: 'CONSIGNE_DEFAUT' }, + 'salon.*.on': { type: 'str', value: 'false' }, + }, + }, + { id: 'n1', type: 'sendPushNotif', z: 't2', title: 'Alerte', body: 'x', adminOnly: true }, + { id: 'f1', type: 'function', z: 't2', func: 'return msg;' }, + { id: 'f2', type: 'function', z: 't2', func: 'return msg;' }, + { id: 'inj1', type: 'inject', z: 't2', repeat: '60' }, +]); + +export function flowsProject(): MemoryFiles { + return new MemoryFiles({ [FLOWS_PATH]: FLOWS_EXPORT }); +}