diff --git a/docs/modules/mosquitto.md b/docs/modules/mosquitto.md new file mode 100644 index 000000000..212033108 --- /dev/null +++ b/docs/modules/mosquitto.md @@ -0,0 +1,29 @@ +# Mosquitto + +## Install + +```bash +npm install @testcontainers/mosquitto --save-dev +``` + +## Examples + +These examples use the following libraries: + +- [mqtt](https://www.npmjs.com/package/mqtt) + + npm install mqtt + +Choose an image from the [container registry](https://hub.docker.com/r/eclipse-mosquitto) and substitute `IMAGE`. + +### Produce/consume a message (anonymous) + + +[](../../packages/modules/mosquitto/src/mosquitto-container.test.ts) inside_block:mosquittoConnectAnonymous + + +### Produce/consume a message (with credentials) + + +[](../../packages/modules/mosquitto/src/mosquitto-container.test.ts) inside_block:mosquittoConnectWithCredentials + diff --git a/mkdocs.yml b/mkdocs.yml index 327f3902d..7ca59af31 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - MinIO: modules/minio.md - Mockserver: modules/mockserver.md - MongoDB: modules/mongodb.md + - Mosquitto: modules/mosquitto.md - MSSQLServer: modules/mssqlserver.md - MySQL: modules/mysql.md - Nats: modules/nats.md diff --git a/package-lock.json b/package-lock.json index 4aeaacd41..c58155371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5279,6 +5279,10 @@ "resolved": "packages/modules/mongodb", "link": true }, + "node_modules/@testcontainers/mosquitto": { + "resolved": "packages/modules/mosquitto", + "link": true + }, "node_modules/@testcontainers/mssqlserver": { "resolved": "packages/modules/mssqlserver", "link": true @@ -15951,6 +15955,17 @@ "mongoose": "^9.7.1" } }, + "packages/modules/mosquitto": { + "name": "@testcontainers/mosquitto", + "version": "12.0.3", + "license": "MIT", + "dependencies": { + "testcontainers": "^12.0.3" + }, + "devDependencies": { + "mqtt": "^5.15.1" + } + }, "packages/modules/mssqlserver": { "name": "@testcontainers/mssqlserver", "version": "12.0.3", diff --git a/packages/modules/mosquitto/Dockerfile b/packages/modules/mosquitto/Dockerfile new file mode 100644 index 000000000..0d1a8ff09 --- /dev/null +++ b/packages/modules/mosquitto/Dockerfile @@ -0,0 +1 @@ +FROM eclipse-mosquitto:2.0.22 diff --git a/packages/modules/mosquitto/package.json b/packages/modules/mosquitto/package.json new file mode 100644 index 000000000..9f7bbd969 --- /dev/null +++ b/packages/modules/mosquitto/package.json @@ -0,0 +1,38 @@ +{ + "name": "@testcontainers/mosquitto", + "version": "12.0.3", + "license": "MIT", + "keywords": [ + "mosquitto", + "mqtt", + "testing", + "docker", + "testcontainers" + ], + "description": "Mosquitto module for Testcontainers", + "homepage": "https://github.com/testcontainers/testcontainers-node#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/testcontainers/testcontainers-node.git" + }, + "bugs": { + "url": "https://github.com/testcontainers/testcontainers-node/issues" + }, + "main": "build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .", + "build": "tsc --project tsconfig.build.json" + }, + "devDependencies": { + "mqtt": "^5.15.1" + }, + "dependencies": { + "testcontainers": "^12.0.3" + } +} diff --git a/packages/modules/mosquitto/src/index.ts b/packages/modules/mosquitto/src/index.ts new file mode 100644 index 000000000..eb898d044 --- /dev/null +++ b/packages/modules/mosquitto/src/index.ts @@ -0,0 +1 @@ +export { MosquittoContainer, StartedMosquittoContainer } from "./mosquitto-container"; diff --git a/packages/modules/mosquitto/src/mosquitto-container.test.ts b/packages/modules/mosquitto/src/mosquitto-container.test.ts new file mode 100644 index 000000000..defd8bb58 --- /dev/null +++ b/packages/modules/mosquitto/src/mosquitto-container.test.ts @@ -0,0 +1,59 @@ +import mqtt from "mqtt"; +import { expect } from "vitest"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import { MosquittoContainer } from "./mosquitto-container"; + +const IMAGE = getImage(__dirname); + +describe("MosquittoContainer", { timeout: 240_000 }, () => { + it("should connect to Mosquitto via MQTT.js (anonymous)", async () => { + // mosquittoConnectAnonymous { + await using container = await new MosquittoContainer(IMAGE).start(); + + expect(container.getConnectionString()).toBe(`mqtt://${container.getHost()}:${container.getPort()}`); + + const mqttClient = await mqtt.connectAsync(container.getConnectionString()); + + const firstMessagePromise = new Promise<{ topic: string; message: Buffer }>((resolve, reject) => { + mqttClient.once("message", (topic, message) => resolve({ topic, message })); + mqttClient.once("error", (err) => reject(err)); + }); + + await mqttClient.subscribeAsync("test"); + await mqttClient.publishAsync("test", "Test Message"); + + const { message } = await firstMessagePromise; + expect(message.toString()).toEqual("Test Message"); + + mqttClient.end(); + // } + }); + + it("should connect to Mosquitto via MQTT.js (with credentials)", async () => { + // mosquittoConnectWithCredentials { + await using container = await new MosquittoContainer(IMAGE) + .withUsername("testuser") + .withPassword("testpass") + .start(); + + expect(container.getConnectionString()).toBe( + `mqtt://testuser:testpass@${container.getHost()}:${container.getPort()}` + ); + + const mqttClient = await mqtt.connectAsync(container.getConnectionString()); + + const firstMessagePromise = new Promise<{ topic: string; message: Buffer }>((resolve, reject) => { + mqttClient.once("message", (topic, message) => resolve({ topic, message })); + mqttClient.once("error", (err) => reject(err)); + }); + + await mqttClient.subscribeAsync("secure"); + await mqttClient.publishAsync("secure", "Secure Message"); + + const { message } = await firstMessagePromise; + expect(message.toString()).toEqual("Secure Message"); + + mqttClient.end(); + // } + }); +}); diff --git a/packages/modules/mosquitto/src/mosquitto-container.ts b/packages/modules/mosquitto/src/mosquitto-container.ts new file mode 100644 index 000000000..f2d90fb0b --- /dev/null +++ b/packages/modules/mosquitto/src/mosquitto-container.ts @@ -0,0 +1,70 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +const MQTT_PORT = 1883; +const CONFIG_PATH = "/mosquitto/config/mosquitto.conf"; +const PASSWORD_FILE_PATH = "/mosquitto/config/passwords"; + +export class MosquittoContainer extends GenericContainer { + private username?: string; + private password?: string; + + constructor(image: string) { + super(image); + this.withExposedPorts(MQTT_PORT).withWaitStrategy(Wait.forListeningPorts()).withStartupTimeout(120_000); + } + + public withUsername(username: string): this { + if (!username) throw new Error("Username should not be empty."); + this.username = username; + return this; + } + + public withPassword(password: string): this { + if (!password) throw new Error("Password should not be empty."); + this.password = password; + return this; + } + + public override async start(): Promise { + if ((this.username === undefined) !== (this.password === undefined)) { + throw new Error("Both username and password must be set together."); + } + + if (this.username !== undefined && this.password !== undefined) { + const config = `listener ${MQTT_PORT}\npassword_file ${PASSWORD_FILE_PATH}\n`; + this.withCopyContentToContainer([{ content: config, target: CONFIG_PATH }]) + .withEnvironment({ MQTT_USER: this.username, MQTT_PASS: this.password }) + .withEntrypoint(["/bin/sh"]) + .withCommand([ + "-c", + `mosquitto_passwd -b -c "${PASSWORD_FILE_PATH}" "$MQTT_USER" "$MQTT_PASS" && chown mosquitto:mosquitto "${PASSWORD_FILE_PATH}" && exec mosquitto -c "${CONFIG_PATH}"`, + ]); + } else { + const config = `listener ${MQTT_PORT}\nallow_anonymous true\n`; + this.withCopyContentToContainer([{ content: config, target: CONFIG_PATH }]); + } + + return new StartedMosquittoContainer(await super.start(), this.username, this.password); + } +} + +export class StartedMosquittoContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly username?: string, + private readonly password?: string + ) { + super(startedTestContainer); + } + + public getPort(): number { + return this.getMappedPort(MQTT_PORT); + } + + public getConnectionString(): string { + if (this.username && this.password) { + return `mqtt://${encodeURIComponent(this.username)}:${encodeURIComponent(this.password)}@${this.getHost()}:${this.getPort()}`; + } + return `mqtt://${this.getHost()}:${this.getPort()}`; + } +} diff --git a/packages/modules/mosquitto/tsconfig.build.json b/packages/modules/mosquitto/tsconfig.build.json new file mode 100644 index 000000000..e9236a57d --- /dev/null +++ b/packages/modules/mosquitto/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "build", + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} diff --git a/packages/modules/mosquitto/tsconfig.json b/packages/modules/mosquitto/tsconfig.json new file mode 100644 index 000000000..0e863cb02 --- /dev/null +++ b/packages/modules/mosquitto/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": [ + "../../testcontainers/src" + ] + } + }, + "exclude": [ + "build" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +}