From c596eca8dcf28a2eafb30a177b04239e52fac4ad Mon Sep 17 00:00:00 2001 From: jona42-ui Date: Sun, 29 Mar 2026 14:51:52 +0300 Subject: [PATCH 1/5] fix(binding-mqtt): replace ?? with || for mqv:filter and mqv:topic fallback The nullish coalescing operator (??) only triggers on null or undefined, not on empty strings. When the URL path is '/', pathname.slice(1) returns '' which is falsy but not null/undefined, causing mqv:filter and mqv:topic to be silently ignored. Fixes #1508 Signed-off-by: jona42-ui --- packages/binding-mqtt/src/mqtt-client.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/binding-mqtt/src/mqtt-client.ts b/packages/binding-mqtt/src/mqtt-client.ts index 9fc120155..b68a79220 100644 --- a/packages/binding-mqtt/src/mqtt-client.ts +++ b/packages/binding-mqtt/src/mqtt-client.ts @@ -65,7 +65,10 @@ export default class MqttClient implements ProtocolClient { const brokerUri: string = `${this.scheme}://` + requestUri.host; // Keeping the path as the topic for compatibility reasons. // Current specification allows only form["mqv:filter"] - const filter = requestUri.pathname.slice(1) ?? form["mqv:filter"]; + const filter = requestUri.pathname.slice(1) || form["mqv:filter"]; + if (filter == null) { + throw new Error("MqttClient: no filter found in form href or mqv:filter"); + } let pool = this.pools.get(brokerUri); @@ -95,7 +98,10 @@ export default class MqttClient implements ProtocolClient { const brokerUri: string = `${this.scheme}://` + requestUri.host; // Keeping the path as the topic for compatibility reasons. // Current specification allows only form["mqv:filter"] - const filter = requestUri.pathname.slice(1) ?? form["mqv:filter"]; + const filter = requestUri.pathname.slice(1) || form["mqv:filter"]; + if (filter == null) { + throw new Error("MqttClient: no filter found in form href or mqv:filter"); + } let pool = this.pools.get(brokerUri); @@ -125,7 +131,10 @@ export default class MqttClient implements ProtocolClient { public async writeResource(form: MqttForm, content: Content): Promise { const requestUri = new url.URL(form.href); const brokerUri = `${this.scheme}://${requestUri.host}`; - const topic = requestUri.pathname.slice(1) ?? form["mqv:topic"]; + const topic = requestUri.pathname.slice(1) || form["mqv:topic"]; + if (topic == null) { + throw new Error("MqttClient: no topic found in form href or mqv:topic"); + } let pool = this.pools.get(brokerUri); From a646ea8ed72d47852f4ce3298492252b86ab62a8 Mon Sep 17 00:00:00 2001 From: jona42-ui Date: Wed, 21 Oct 2026 09:46:34 +0300 Subject: [PATCH 2/5] test(binding-mqtt): add tests for mqv:filter fallback and missing filter Verify that subscribeResource uses mqv:filter when the form href has no path (#1508), and that it rejects when neither a path nor mqv:filter is provided. Signed-off-by: jona42-ui --- .../test/mqtt-client-subscribe-test.unit.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts b/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts index 39a60bdc6..6ef1d4672 100644 --- a/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts +++ b/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts @@ -112,6 +112,53 @@ describe("MQTT client implementation - unit", () => { }) .catch((err) => done(err)); }).timeout(10000); + + it("should subscribe using mqv:filter when href has no path", (done: Mocha.Done) => { + const mqttClient = new MqttClient(); + const topic = "test-filter"; + const subscribeForm: MqttForm = { + href: brokerUri, + "mqv:filter": topic, + "mqv:qos": "2", + "mqv:retain": false, + }; + const publishForm: MqttForm = { + href: brokerUri + "/" + topic, + "mqv:qos": "2", + "mqv:retain": false, + }; + + mqttClient + .subscribeResource(subscribeForm, async (value: Content) => { + try { + const data = await value.toBuffer(); + expect(data.toString()).to.be.equal("test"); + done(); + } catch (err) { + done(err); + } finally { + await mqttClient.stop(); + } + }) + .then(async () => { + await mqttClient.invokeResource(publishForm, new Content("", Readable.from(Buffer.from("test")))); + }) + .catch((err) => done(err)); + }).timeout(10000); + + it("should throw an error when href has no path and no mqv:filter", async () => { + const mqttClient = new MqttClient(); + const form: MqttForm = { + href: brokerUri, + "mqv:qos": "2", + "mqv:retain": false, + }; + + await mqttClient + .subscribeResource(form, () => {}) + .should.eventually.be.rejectedWith(Error, "MqttClient: no filter found in form href or mqv:filter"); + await mqttClient.stop(); + }); }); describe("tests with authorization", () => { From 00803d8a96eb122b920c079704e20f7da54fa505 Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 21 Oct 2026 09:38:14 +0200 Subject: [PATCH 3/5] fix(binding-mqtt): use mqv:topic for actions --- packages/binding-mqtt/src/mqtt-client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/binding-mqtt/src/mqtt-client.ts b/packages/binding-mqtt/src/mqtt-client.ts index b68a79220..4eba3ac30 100644 --- a/packages/binding-mqtt/src/mqtt-client.ts +++ b/packages/binding-mqtt/src/mqtt-client.ts @@ -155,9 +155,13 @@ export default class MqttClient implements ProtocolClient { public async invokeResource(form: MqttForm, content: Content): Promise { const requestUri = new url.URL(form.href); - const topic = requestUri.pathname.slice(1); + const topic = requestUri.pathname.slice(1) || form["mqv:topic"]; const brokerUri = `${this.scheme}://${requestUri.host}`; + if (topic == null) { + throw new Error("MqttClient: no topic found in form href or mqv:topic"); + } + let pool = this.pools.get(brokerUri); if (pool == null) { From 5cc1105df67343607d2d71144161866d565be54d Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 21 Oct 2026 09:38:54 +0200 Subject: [PATCH 4/5] test(binding-mqtt): unit test uri and form values parsing logic --- .../test/mqtt-client-form-test.unit.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/binding-mqtt/test/mqtt-client-form-test.unit.ts diff --git a/packages/binding-mqtt/test/mqtt-client-form-test.unit.ts b/packages/binding-mqtt/test/mqtt-client-form-test.unit.ts new file mode 100644 index 000000000..61a98e814 --- /dev/null +++ b/packages/binding-mqtt/test/mqtt-client-form-test.unit.ts @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { expect } from "chai"; +import sinon from "sinon"; +import { MqttClient } from "../src/mqtt"; +import MQTTMessagePool from "../src/mqtt-message-pool"; +import { Readable } from "stream"; +import { Content } from "@node-wot/core"; + +describe("MQTT client form parsing - unit", () => { + let subscribe: sinon.SinonStub; + let publish: sinon.SinonStub; + + beforeEach(() => { + sinon.stub(MQTTMessagePool.prototype, "connect").resolves(); + subscribe = sinon.stub(MQTTMessagePool.prototype, "subscribe").resolves(); + publish = sinon.stub(MQTTMessagePool.prototype, "publish").resolves(); + sinon.stub(MQTTMessagePool.prototype, "unsubscribe").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should fall back to mqv:filter when href has no path in subscribe", async () => { + await new MqttClient().subscribeResource({ href: "mqtt://localhost:1883", "mqv:filter": "foo/bar" }, () => {}); + + expect(subscribe.firstCall.args[0]).to.equal("foo/bar"); + }); + + it("should fall back to mqv:filter when href has no path in read property", async () => { + // readResource resolves only once a message arrives on the subscription + subscribe.callsFake(async (filter: string, onMessage: (topic: string, message: Buffer) => void) => + onMessage(filter, Buffer.from("42")) + ); + + await new MqttClient().readResource({ href: "mqtt://localhost:1883", "mqv:filter": "foo/bar" }); + + expect(subscribe.firstCall.args[0]).to.equal("foo/bar"); + }); + + it("should fall back to mqv:topic when href has no path in write property", async () => { + const stream = Readable.from(Buffer.from("23")); + await new MqttClient().writeResource( + { href: "mqtt://localhost:1883", "mqv:topic": "foo/bar" }, + new Content("application/json", stream) + ); + + expect(publish.firstCall.args[0]).to.equal("foo/bar"); + }); + + it("should fall back to mqv:topic when href has no path in invoke action", async () => { + const stream = Readable.from(Buffer.from("23")); + await new MqttClient().invokeResource( + { href: "mqtt://localhost:1883", "mqv:topic": "foo/bar" }, + new Content("application/json", stream) + ); + + expect(publish.firstCall.args[0]).to.equal("foo/bar"); + }); +}); From a6ee53d19dd037ee7e296923435c6ba1f7c91f9a Mon Sep 17 00:00:00 2001 From: reluc Date: Wed, 21 Oct 2026 09:42:34 +0200 Subject: [PATCH 5/5] Revert "test(binding-mqtt): add tests for mqv:filter fallback and missing filter" This reverts commit aca20dd13e70d11decd9a32155ccefb6b8622e2e. --- .../test/mqtt-client-subscribe-test.unit.ts | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts b/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts index 6ef1d4672..39a60bdc6 100644 --- a/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts +++ b/packages/binding-mqtt/test/mqtt-client-subscribe-test.unit.ts @@ -112,53 +112,6 @@ describe("MQTT client implementation - unit", () => { }) .catch((err) => done(err)); }).timeout(10000); - - it("should subscribe using mqv:filter when href has no path", (done: Mocha.Done) => { - const mqttClient = new MqttClient(); - const topic = "test-filter"; - const subscribeForm: MqttForm = { - href: brokerUri, - "mqv:filter": topic, - "mqv:qos": "2", - "mqv:retain": false, - }; - const publishForm: MqttForm = { - href: brokerUri + "/" + topic, - "mqv:qos": "2", - "mqv:retain": false, - }; - - mqttClient - .subscribeResource(subscribeForm, async (value: Content) => { - try { - const data = await value.toBuffer(); - expect(data.toString()).to.be.equal("test"); - done(); - } catch (err) { - done(err); - } finally { - await mqttClient.stop(); - } - }) - .then(async () => { - await mqttClient.invokeResource(publishForm, new Content("", Readable.from(Buffer.from("test")))); - }) - .catch((err) => done(err)); - }).timeout(10000); - - it("should throw an error when href has no path and no mqv:filter", async () => { - const mqttClient = new MqttClient(); - const form: MqttForm = { - href: brokerUri, - "mqv:qos": "2", - "mqv:retain": false, - }; - - await mqttClient - .subscribeResource(form, () => {}) - .should.eventually.be.rejectedWith(Error, "MqttClient: no filter found in form href or mqv:filter"); - await mqttClient.stop(); - }); }); describe("tests with authorization", () => {