diff --git a/CHANGELOG.md b/CHANGELOG.md index 44eb3802..5b392e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ This page shows a detailed overview of the changes between versions without the - Enhancement: Introduces Websocket Schema version 13 (backward compatible) - (MindFreeze) Adds Websocket command `get_network_topology` and event `network_topology_updated` to expose the Thread and WiFi network details for external visualization +- Feature: (lboue) Dashboard endpoint view shows a "Client Clusters" panel listing the Descriptor's ClientList clusters when the endpoint has a Binding cluster, marking each as "Bound" or "Not bound" +- Fix: Dashboard endpoint view refreshes its cluster list when a cluster appears or disappears on the node, or the viewed endpoint changes +- Fix: Dashboard attribute reads that feed its local cache are now fabric-filtered like the subscription; an unfiltered read is data the server deliberately does not cache, so other fabrics' entries could linger in the dashboard's view — including phantom binding rows that shifted the index the delete button acts on +- Fix: Dashboard ACL and binding edits abort instead of writing back a truncated list when the preceding read did not return the list; a partial read failure could previously wipe a node's access control list ## 1.3.3 (2026-07-28) diff --git a/packages/dashboard/public/index.html b/packages/dashboard/public/index.html index 0bc496a8..30d4749b 100644 --- a/packages/dashboard/public/index.html +++ b/packages/dashboard/public/index.html @@ -37,6 +37,7 @@ --md-sys-color-surface-container-high: #fff; --md-sys-color-surface-container-highest: #f5f5f5; --md-sys-color-secondary-container: #e0e0e0; + --md-sys-color-on-secondary-container: #1d192b; --md-sys-color-error: #b3261e; --md-sys-color-on-error: #fff; --md-sys-color-error-container: #f9dedc; diff --git a/packages/dashboard/src/components/dialogs/acl/acl-actions.ts b/packages/dashboard/src/components/dialogs/acl/acl-actions.ts index 2440d3b4..1c29a20c 100644 --- a/packages/dashboard/src/components/dialogs/acl/acl-actions.ts +++ b/packages/dashboard/src/components/dialogs/acl/acl-actions.ts @@ -23,11 +23,16 @@ function toApiAcl(e: AccessControlEntryStruct): AccessControlEntry { } /** - * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow - * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown. + * Read the node's ACL + CurrentFabricIndex fresh and narrow to our fabric. Fails rather than risk + * writing back other fabrics' entries if the index is unknown. */ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise { - const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]); + const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"], undefined, true); + // A read reports per-path failures by omitting the path, and the ACL is rewritten whole — an + // absent list would be written back as an empty one, locking every controller out of the node. + if (!("0/31/0" in res)) { + throw new Error(`Could not read the access control list (0/31/0) from node ${nodeId}`); + } const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v)); const fi = res["0/62/5"]; if (typeof fi !== "number") { diff --git a/packages/dashboard/src/components/dialogs/binding/binding-actions.ts b/packages/dashboard/src/components/dialogs/binding/binding-actions.ts index 38e1fec6..30d788ca 100644 --- a/packages/dashboard/src/components/dialogs/binding/binding-actions.ts +++ b/packages/dashboard/src/components/dialogs/binding/binding-actions.ts @@ -15,6 +15,7 @@ import { nodeIdKey, subjectsInclude, } from "../../../util/access-control.js"; +import { BINDING_CLUSTER_ID } from "../../../util/binding.js"; import { AccessControlEntryDataTransformer, type AccessControlEntryStruct } from "../acl/model.js"; import { BindingEntryDataTransformer, type BindingEntryStruct } from "./model.js"; @@ -45,12 +46,25 @@ function requireFabricIndex(res: Record, nodeId: number | bigin } /** - * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow - * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown. + * A read reports per-path failures by omitting the path, and these lists are rewritten whole — an + * absent list would be written back as an empty one, wiping every entry of our fabric. + */ +function requireList(res: Record, path: string, nodeId: number | bigint): unknown { + if (!(path in res)) { + throw new Error(`Could not read ${path} from node ${nodeId}`); + } + return res[path]; +} + +/** + * Read the node's ACL + CurrentFabricIndex fresh and narrow to our fabric. Fails rather than risk + * writing back other fabrics' entries if the index is unknown. */ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise { - const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]); - const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v)); + const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"], undefined, true); + const all = attributeArray(requireList(res, "0/31/0", nodeId)).map(v => + AccessControlEntryDataTransformer.transform(v), + ); return entriesForFabric(all, requireFabricIndex(res, nodeId)); } @@ -59,8 +73,9 @@ async function freshOurBindings( nodeId: number | bigint, endpoint: number, ): Promise { - const res = await client.readAttribute(nodeId, [`${endpoint}/30/0`, "0/62/5"]); - const all = attributeArray(res[`${endpoint}/30/0`]).map(v => BindingEntryDataTransformer.transform(v)); + const path = `${endpoint}/${BINDING_CLUSTER_ID}/0`; + const res = await client.readAttribute(nodeId, [path, "0/62/5"], undefined, true); + const all = attributeArray(requireList(res, path, nodeId)).map(v => BindingEntryDataTransformer.transform(v)); const fabricIndex = requireFabricIndex(res, nodeId); return all.filter(b => b.fabricIndex === fabricIndex); } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts index bfe58fbf..ac7dc15c 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts @@ -64,7 +64,7 @@ class AccessControlClusterCommands extends BaseClusterCommands { if (this._loadedKey === key) return; this._loadedKey = key; try { - const res = await this.client.readAttribute(node.node_id, ["0/31/0", "0/62/5"]); + const res = await this.client.readAttribute(node.node_id, ["0/31/0", "0/62/5"], undefined, true); if (!this.isSameContext(node, endpoint)) return; for (const [k, v] of Object.entries(res)) this.node.attributes[k] = v; this.requestUpdate(); diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts index 1a6fcf18..fb48b878 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts @@ -21,13 +21,13 @@ import type { BindingEntryStruct } from "../../../components/dialogs/binding/mod import { showNodeBindingDialog } from "../../../components/dialogs/binding/show-node-binding-dialog.js"; import { nodeIdKey } from "../../../util/access-control.js"; import { handleAsync } from "../../../util/async-handler.js"; -import { readBindings, reverseAclState, type ReverseAclState } from "../../../util/binding.js"; +import { BINDING_CLUSTER_ID, readBindings, reverseAclState, type ReverseAclState } from "../../../util/binding.js"; import { getEndpointDeviceTypes } from "../../../util/endpoints.js"; import { getDeviceName } from "../../../util/node-name.js"; import { BaseClusterCommands } from "../base-cluster-commands.js"; import { registerClusterCommands } from "../registry.js"; -const CLUSTER_ID = 30; +const CLUSTER_ID = BINDING_CLUSTER_ID; @customElement("binding-cluster-commands") class BindingClusterCommands extends BaseClusterCommands { @@ -48,7 +48,7 @@ class BindingClusterCommands extends BaseClusterCommands { this._unsubscribe?.(); } - /** Read the (fabric-scoped) binding attribute and each target's ACL into the cache on open. */ + /** Read the binding attribute and each target's ACL into the cache on open. */ private async _ensureLoaded() { if (!this.client || !this.node || !this.node.available || this.endpoint === undefined) return; const node = this.node; @@ -57,7 +57,7 @@ class BindingClusterCommands extends BaseClusterCommands { if (this._loadedKey === key) return; this._loadedKey = key; try { - await this._readInto(node.node_id, [`${endpoint}/30/0`, "0/62/5"]); + await this._readInto(node.node_id, [`${endpoint}/${CLUSTER_ID}/0`, "0/62/5"]); const targets = new Set( readBindings(node, endpoint) .map(b => (b.node != null ? nodeIdKey(b.node) : undefined)) @@ -76,8 +76,13 @@ class BindingClusterCommands extends BaseClusterCommands { } } + /** + * Fabric-filtered so the merged values match what the subscription caches: matter.js only feeds + * a one-shot read back into the datasource when its filtering matches the subscription's, so an + * unfiltered response would put other fabrics' entries into the cache and nothing would clear them. + */ private async _readInto(nodeId: number | bigint, path: string | string[]) { - const res = await this.client.readAttribute(nodeId, path); + const res = await this.client.readAttribute(nodeId, path, undefined, true); const node = this.client.nodes[nodeIdKey(nodeId)]; if (node) for (const [k, v] of Object.entries(res)) node.attributes[k] = v; } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts index 4879e360..c1d62d49 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts @@ -444,7 +444,11 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { }); } - /** Non-fabric-filtered RegisteredClients read; counts clients on other fabrics. */ + /** + * Non-fabric-filtered RegisteredClients read; counts clients on other fabrics. Its result must not + * reach the attribute cache: node ids are per-fabric and can collide, so a foreign entry could + * false-positive `isRegisteredByUs`, and the subscription keeps that attribute fabric-filtered. + */ private async _otherClientCount(node: MatterNode, endpoint: number): Promise { const ourFabricIndexRaw = node.attributes[CURRENT_FABRIC_INDEX_PATH]; const result = await this.client.readAttribute( @@ -452,7 +456,10 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { [REGISTERED_CLIENTS_PATH, CURRENT_FABRIC_INDEX_PATH], this._actionTimeoutMs, ); - if (this.isSameContext(node, endpoint)) Object.assign(this.node.attributes, result); + const currentFabricIndex = result[CURRENT_FABRIC_INDEX_PATH]; + if (this.isSameContext(node, endpoint) && typeof currentFabricIndex === "number") { + this.node.attributes[CURRENT_FABRIC_INDEX_PATH] = currentFabricIndex; + } const clients = decodeRegisteredClients(result[REGISTERED_CLIENTS_PATH]); const ourFabricIndex = result[CURRENT_FABRIC_INDEX_PATH] ?? ourFabricIndexRaw; return otherFabricClientCount(clients, typeof ourFabricIndex === "number" ? ourFabricIndex : undefined); diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 39258e32..16bd4ad3 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -33,7 +33,7 @@ import { DESCRIPTOR_CLUSTER_ID, TAG_LIST_ATTR, } from "../util/semantic-tags.js"; -import { notFoundStyles } from "../util/shared-styles.js"; +import { infoPanelStyles, notFoundStyles } from "../util/shared-styles.js"; import { BaseClusterCommands, getClusterCommandsTag } from "./cluster-commands/index.js"; import { bindingContext } from "./components/context.js"; @@ -261,9 +261,10 @@ class MatterClusterView extends LitElement { this._refreshState = { ...this._refreshState, [attributeId]: "loading" }; try { - const result = await this.client.readAttribute(nodeId, path); + // Fabric-filtered to match the subscription: matter.js discards a read whose filtering + // differs, so an unfiltered one would put values into the cache that nothing ever refreshes. + const result = await this.client.readAttribute(nodeId, path, undefined, true); if (!isSameContext()) return; - // Defensive merge — attribute_updated events usually do this already. for (const [key, value] of Object.entries(result)) { this.node.attributes[key] = value; } @@ -504,6 +505,7 @@ class MatterClusterView extends LitElement { static override styles = [ notFoundStyles, + infoPanelStyles, css` :host { display: block; @@ -725,42 +727,6 @@ class MatterClusterView extends LitElement { border-radius: 3px; } - .info-panel { - background-color: var(--md-sys-color-surface-container); - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: 12px; - padding: 14px 16px; - } - - .info-section + .info-section { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--md-sys-color-outline-variant); - } - - .info-section-header { - font-weight: 500; - color: var(--md-sys-color-on-surface); - margin-bottom: 10px; - } - - .chip-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .chip { - font-size: 0.85rem; - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - padding: 4px 10px; - border-radius: 8px; - } - .chip.chip-error { color: var(--md-sys-color-on-error-container); background: var(--md-sys-color-error-container); diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index 84af48cf..be4688b1 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -13,16 +13,17 @@ import "@material/web/list/list-item"; import { consume } from "@lit/context"; import { MatterClient, MatterNode, isTestNodeId } from "@matter-server/ws-client"; import { mdiAlertCircleOutline, mdiChevronRight } from "@mdi/js"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, type TemplateResult, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { guard } from "lit/directives/guard.js"; import "./cluster-commands/clusters/binding-commands.js"; import { clientContext, tickContext } from "../client/client-context.js"; import { clusters } from "../client/models/descriptions.js"; import "../components/ha-svg-icon"; +import { BINDING_CLUSTER_ID, boundClientClusterIds, sourceClientClusters } from "../util/binding.js"; import { getEndpointDeviceTypes } from "../util/endpoints.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; -import { notFoundStyles } from "../util/shared-styles.js"; +import { infoPanelStyles, notFoundStyles } from "../util/shared-styles.js"; import { bindingContext } from "./components/context.js"; declare global { @@ -77,7 +78,10 @@ class MatterEndpointView extends LitElement { this.client.serverInfo.fabric_index, isTestNodeId(this.node.node_id), ); - const nodeHex = formatNodeAddress(fabricIndex, this.node.node_id); + const nodeId = this.node.node_id; + const nodeHex = formatNodeAddress(fabricIndex, nodeId); + const endpointClusters = getUniqueClusters(this.node, this.endpoint); + const hasBindingCluster = endpointClusters.includes(BINDING_CLUSTER_ID); return html` + ${hasBindingCluster ? this._renderClientClustersSection(this.node) : nothing} + ${ - getUniqueClusters(this.node, this.endpoint).includes(30) + hasBindingCluster ? html`
` : nothing @@ -119,19 +125,20 @@ class MatterEndpointView extends LitElement { .join(" / ")} - ${guard([this.node?.attributes.length], () => - getUniqueClusters(this.node!, this.endpoint).map(cluster => { - return html` - -
${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}
-
ClusterId ${cluster} (${formatHex(cluster)})
- -
- `; - }), + ${guard( + [nodeId, this.endpoint, this.node.attributes, Object.keys(this.node.attributes).length], + () => + endpointClusters.map(cluster => { + return html` + +
+ ${clusters[cluster]?.label ?? "Custom/Unknown Cluster"} +
+
ClusterId ${cluster} (${formatHex(cluster)})
+ +
+ `; + }), )} @@ -142,8 +149,36 @@ class MatterEndpointView extends LitElement { history.back(); } + private _renderClientClustersSection(node: MatterNode): TemplateResult | typeof nothing { + const clientClusters = sourceClientClusters(node, this.endpoint); + if (clientClusters.length === 0) return nothing; + const boundClusterIds = boundClientClusterIds(node, this.endpoint); + + return html` +
+
+
+
Client Clusters
+
    + ${clientClusters.map(id => { + const bound = boundClusterIds.has(id); + return html` +
  • + ${clusters[id]?.label ?? "Custom/Unknown Cluster"} ${formatHex(id)} + ${bound ? "Bound" : "Not bound"} +
  • + `; + })} +
+
+
+
+ `; + } + static override styles = [ notFoundStyles, + infoPanelStyles, css` :host { display: block; @@ -180,6 +215,18 @@ class MatterEndpointView extends LitElement { font-weight: bold; font-size: 0.8em; } + + .chip.chip-bound { + color: var(--md-sys-color-on-primary-container); + background: var(--md-sys-color-primary-container); + } + + .chip-state { + font-weight: 500; + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid currentcolor; + } `, ]; } diff --git a/packages/dashboard/src/util/binding.ts b/packages/dashboard/src/util/binding.ts index 4ea87ff5..ea74d3fd 100644 --- a/packages/dashboard/src/util/binding.ts +++ b/packages/dashboard/src/util/binding.ts @@ -20,10 +20,12 @@ import { subjectsInclude, } from "./access-control.js"; -const BINDING_KEY_RE = /^(\d+)\/30\/0$/; +export const BINDING_CLUSTER_ID = 30; + +const BINDING_KEY_RE = new RegExp(`^(\\d+)/${BINDING_CLUSTER_ID}/0$`); export function readBindings(node: MatterNode, endpoint: number): BindingEntryStruct[] { - return attributeArray(node.attributes[`${endpoint}/30/0`]).map(value => + return attributeArray(node.attributes[`${endpoint}/${BINDING_CLUSTER_ID}/0`]).map(value => BindingEntryDataTransformer.transform(value), ); } @@ -47,9 +49,7 @@ export function readAllBindings(node: MatterNode): EndpointBinding[] { } function numberList(node: MatterNode, key: string): number[] { - const raw = node.attributes[key]; - if (!Array.isArray(raw)) return new Array(); - return raw.map(v => Number(v)); + return attributeArray(node.attributes[key]).map(v => Number(v)); } export function targetServerClusters(node: MatterNode, endpoint: number): number[] { @@ -60,6 +60,25 @@ export function sourceClientClusters(node: MatterNode, endpoint: number): number return numberList(node, `${endpoint}/29/2`); } +/** + * Client clusters on the endpoint that an existing binding of our fabric already covers. + * + * Per the Binding cluster spec an entry with no Cluster field covers every client cluster on the + * endpoint — whole-endpoint unicast and group bindings both take that form. The fabric filter keeps + * the result correct even if a caller ever populates the cache from an unfiltered read. + */ +export function boundClientClusterIds(node: MatterNode, endpoint: number): Set { + const fabricIndex = nodeFabricIndex(node); + const bindings = readBindings(node, endpoint).filter( + b => fabricIndex === undefined || b.fabricIndex === fabricIndex, + ); + const bound = new Set(bindings.map(b => b.cluster).filter((c): c is number => c !== undefined)); + if (bindings.some(b => b.cluster === undefined && (b.group !== undefined || b.endpoint !== undefined))) { + for (const cluster of sourceClientClusters(node, endpoint)) bound.add(cluster); + } + return bound; +} + export interface BindableClusters { bindable: number[]; otherTarget: number[]; diff --git a/packages/dashboard/src/util/shared-styles.ts b/packages/dashboard/src/util/shared-styles.ts index ae9bfb66..39e059a1 100644 --- a/packages/dashboard/src/util/shared-styles.ts +++ b/packages/dashboard/src/util/shared-styles.ts @@ -34,6 +34,45 @@ export const reducedMotionStyles = css` } `; +/** Bordered panel holding one or more `.info-section` blocks, each optionally listing `.chip`s. */ +export const infoPanelStyles = css` + .info-panel { + background-color: var(--md-sys-color-surface-container); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: 12px; + padding: 14px 16px; + } + + .info-section + .info-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--md-sys-color-outline-variant); + } + + .info-section-header { + font-weight: 500; + color: var(--md-sys-color-on-surface); + margin-bottom: 10px; + } + + .chip-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .chip { + font-size: 0.85rem; + color: var(--md-sys-color-on-secondary-container); + background: var(--md-sys-color-secondary-container); + padding: 4px 10px; + border-radius: 8px; + } +`; + export const notFoundStyles = css` .not-found { display: flex; diff --git a/packages/dashboard/test/BindingUtilTest.ts b/packages/dashboard/test/BindingUtilTest.ts index 7a9e929f..2c8f8cb4 100644 --- a/packages/dashboard/test/BindingUtilTest.ts +++ b/packages/dashboard/test/BindingUtilTest.ts @@ -8,6 +8,7 @@ import { MatterNode, type MatterNodeData } from "@matter-server/ws-client"; import type { BindingEntryStruct } from "../src/components/dialogs/binding/model.js"; import { bindableClusters, + boundClientClusterIds, readAllBindings, readBindings, reverseAclState, @@ -64,6 +65,71 @@ describe("binding util", () => { expect(result.otherTarget).to.deep.equal([8]); }); + it("boundClientClusterIds collects the clusters covered by existing binding entries", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "1": 2, "3": 1, "4": 6, "254": 1 }] }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + }); + + it("boundClientClusterIds returns an empty set when the endpoint has no bindings", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768] }); + expect(boundClientClusterIds(n, 1).size).to.equal(0); + }); + + it("boundClientClusterIds ignores entries belonging to another fabric", () => { + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [ + { "1": 2, "3": 1, "4": 6, "254": 1 }, + { "1": 3, "3": 1, "4": 768, "254": 2 }, + ], + }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + const foreignWildcard = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [{ "1": 3, "3": 1, "254": 2 }], + }); + expect(boundClientClusterIds(foreignWildcard, 1).size).to.equal(0); + }); + + it("boundClientClusterIds treats a cluster-less entry as covering every client cluster", () => { + // Whole-endpoint unicast binding: Node + Endpoint, no Cluster. + const wholeEndpoint = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "1": 2, "3": 1, "254": 1 }] }); + expect([...boundClientClusterIds(wholeEndpoint, 1)].sort((a, b) => a - b)).to.deep.equal([6, 768]); + // Group binding: Group only, no Cluster. + const groupBinding = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "2": 5, "254": 1 }] }); + expect([...boundClientClusterIds(groupBinding, 1)].sort((a, b) => a - b)).to.deep.equal([6, 768]); + }); + + it("boundClientClusterIds keeps cluster-specific entries alongside a cluster-less one", () => { + // 8 is bound but absent from the ClientList, so the wildcard expansion must not replace it. + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [ + { "1": 2, "3": 1, "4": 8, "254": 1 }, + { "1": 2, "3": 1, "254": 1 }, + ], + }); + expect([...boundClientClusterIds(n, 1)].sort((a, b) => a - b)).to.deep.equal([6, 8, 768]); + }); + + it("boundClientClusterIds does not expand an entry that targets nothing", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "254": 1 }] }); + expect(boundClientClusterIds(n, 1).size).to.equal(0); + }); + + it("boundClientClusterIds only looks at the requested endpoint", () => { + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [{ "1": 2, "3": 1, "4": 6, "254": 1 }], + "2/30/0": [{ "1": 2, "3": 1, "4": 768, "254": 1 }], + }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + }); + it("reverseAclState returns present/missing/overPrivileged/cannotVerify", () => { const target = node( { "0/62/5": 1, "0/31/0": [{ "1": 3, "2": 2, "3": [1], "4": [{ "0": 6, "1": 1 }], "254": 1 }] }, diff --git a/packages/dashboard/test/WritePathGuardTest.ts b/packages/dashboard/test/WritePathGuardTest.ts new file mode 100644 index 00000000..b03fa688 --- /dev/null +++ b/packages/dashboard/test/WritePathGuardTest.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MatterNode, type MatterClient, type MatterNodeData } from "@matter-server/ws-client"; +import { deleteAclEntry } from "../src/components/dialogs/acl/acl-actions.js"; +import { addBinding, deleteBindingAtIndex } from "../src/components/dialogs/binding/binding-actions.js"; + +function node(attributes: Record, node_id: number | bigint = 1): MatterNode { + const data: MatterNodeData = { + node_id, + date_commissioned: "", + last_interview: "", + interview_version: 1, + available: true, + is_bridge: false, + attributes, + attribute_subscriptions: [], + }; + return new MatterNode(data); +} + +interface Writes { + acl: number; + binding: number; +} + +/** A read that answers only the paths in `res` — a per-path failure shows up as an omitted path. */ +function clientReading(res: Record): { client: MatterClient; writes: Writes } { + const writes: Writes = { acl: 0, binding: 0 }; + const client = { + nodes: {}, + readAttribute: async () => res, + setACLEntry: async () => { + writes.acl++; + }, + setNodeBinding: async () => { + writes.binding++; + }, + } as unknown as MatterClient; + return { client, writes }; +} + +describe("fabric-scoped write paths", () => { + it("deleteAclEntry refuses to write when the ACL read returned no list", async () => { + const { client, writes } = clientReading({ "0/62/5": 1 }); + await expect(deleteAclEntry(client, 1, "whatever")).to.be.rejectedWith(/0\/31\/0/); + expect(writes.acl).to.equal(0); + }); + + it("deleteAclEntry writes the remaining entries when the read succeeded", async () => { + const { client, writes } = clientReading({ + "0/62/5": 1, + "0/31/0": [{ "1": 5, "2": 2, "3": [9], "254": 1 }], + }); + await deleteAclEntry(client, 1, "whatever"); + expect(writes.acl).to.equal(1); + }); + + it("deleteBindingAtIndex refuses to write when the binding read returned no list", async () => { + const { client, writes } = clientReading({ "0/62/5": 1 }); + await expect(deleteBindingAtIndex(client, node({}), 1, 0)).to.be.rejectedWith(/1\/30\/0/); + expect(writes.binding).to.equal(0); + }); + + it("addBinding refuses to write when the binding read returned no list", async () => { + // The target's ACL already grants Operate on 1/6, so ensureBindingAcl passes and the run + // reaches the binding read, which answers only 0/62/5. + const { client, writes } = clientReading({ + "0/62/5": 1, + "0/31/0": [{ "1": 3, "2": 2, "3": [1], "4": [{ "0": 6, "1": 1 }], "254": 1 }], + }); + await expect(addBinding(client, node({}, 1), 1, 2, 1, 6)).to.be.rejectedWith(/1\/30\/0/); + expect(writes.acl).to.equal(0); + expect(writes.binding).to.equal(0); + }); +});