Files

117 lines
4.6 KiB
TypeScript

/**
* Unit tests for the MQTT notification channel's pure URL parser + message builder
* (mqtt-core.ts). No broker, no `mqtt` import - just the Apprise-URL grammar and the
* SSRF host guard.
*
* Run with: bun test tests/mqtt-notify.test.ts
*/
import { describe, test, expect } from 'bun:test';
import { parseMqttUrl, buildMqttMessage } from '../src/lib/server/notifications/mqtt-core';
describe('parseMqttUrl', () => {
test('basic mqtt://host/topic defaults to tcp port 1883, qos 0, retain false', () => {
const r = parseMqttUrl('mqtt://192.168.1.5/dockhand');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.target.brokerUrl).toBe('mqtt://192.168.1.5:1883');
expect(r.target.host).toBe('192.168.1.5');
expect(r.target.topic).toBe('dockhand');
expect(r.target.qos).toBe(0);
expect(r.target.retain).toBe(false);
expect(r.target.username).toBeUndefined();
});
test('credentials, custom port, multi-segment topic, qos and retain', () => {
const r = parseMqttUrl('mqtt://user:[email protected]:1884/dockhand/events?qos=1&retain=true');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.target.brokerUrl).toBe('mqtt://broker.lan:1884');
expect(r.target.topic).toBe('dockhand/events');
expect(r.target.qos).toBe(1);
expect(r.target.retain).toBe(true);
expect(r.target.username).toBe('user');
expect(r.target.password).toBe('s3cret');
});
test('mqtts:// uses TLS default port 8883', () => {
const r = parseMqttUrl('mqtts://broker.example.com/topic');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.target.brokerUrl).toBe('mqtts://broker.example.com:8883');
});
test('percent-encoded credentials are decoded', () => {
const r = parseMqttUrl('mqtt://user%40host:p%[email protected]/topic');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.target.username).toBe('user@host');
expect(r.target.password).toBe('p:ss');
});
test('rejects a non-mqtt scheme', () => {
const r = parseMqttUrl('http://broker/topic');
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toContain('not allowed');
});
test('rejects a missing topic', () => {
const r = parseMqttUrl('mqtt://broker.lan');
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toContain('topic');
});
test('rejects a wildcard in the publish topic', () => {
expect(parseMqttUrl('mqtt://broker.lan/a/+/b').ok).toBe(false);
// A literal '#' in a URL is the fragment delimiter, so it must be percent-encoded
// to reach the topic; once decoded, the wildcard guard rejects it.
expect(parseMqttUrl('mqtt://broker.lan/a/%23').ok).toBe(false);
});
test('rejects an invalid qos', () => {
const r = parseMqttUrl('mqtt://broker.lan/topic?qos=3');
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toContain('qos');
});
// SSRF: a private LAN broker is allowed (self-hosted Mosquitto), but loopback and
// cloud-metadata are blocked - same policy as the HTTP notification channels.
test('allows a private LAN broker (v4 and v6)', () => {
expect(parseMqttUrl('mqtt://10.0.0.9/topic').ok).toBe(true);
expect(parseMqttUrl('mqtt://192.168.1.50/topic').ok).toBe(true);
expect(parseMqttUrl('mqtt://[fd00::1]/topic').ok).toBe(true); // IPv6 ULA
});
test('blocks loopback, metadata, and IPv6/IPv4-mapped loopback brokers', () => {
expect(parseMqttUrl('mqtt://127.0.0.1/topic').ok).toBe(false);
expect(parseMqttUrl('mqtt://localhost/topic').ok).toBe(false);
expect(parseMqttUrl('mqtt://169.254.169.254/topic').ok).toBe(false);
expect(parseMqttUrl('mqtt://[::1]/topic').ok).toBe(false); // IPv6 loopback
expect(parseMqttUrl('mqtt://[::ffff:127.0.0.1]/topic').ok).toBe(false); // v4-mapped loopback
});
});
describe('buildMqttMessage', () => {
test('produces JSON with title (env-qualified), message, type and the given timestamp', () => {
const body = buildMqttMessage(
{ title: 'Container started', message: 'nginx started', type: 'success', eventType: 'container_started', environmentId: 2, environmentName: 'prod' },
'2026-01-01T00:00:00.000Z'
);
const parsed = JSON.parse(body);
expect(parsed.message).toBe('nginx started');
expect(parsed.type).toBe('success');
expect(parsed.eventType).toBe('container_started');
expect(parsed.environmentName).toBe('prod');
expect(parsed.timestamp).toBe('2026-01-01T00:00:00.000Z');
// title runs through titleWithEnv, so it still contains the base title
expect(parsed.title).toContain('Container started');
});
test('defaults type to info when absent', () => {
const parsed = JSON.parse(buildMqttMessage({ title: 't', message: 'm' }, '2026-01-01T00:00:00.000Z'));
expect(parsed.type).toBe('info');
});
});