'use strict'; // B10: push-notification DELIVERY — verifies the message-building + HTTP send path // for each channel against a local sink (no external network), plus error handling, // dispatch, and that the auth token never leaks into the message body. const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); const { _push } = require('../services/notificationService'); const { sendNtfy, sendGotify, sendDiscord, sendPushToUser } = _push; // Local HTTP sink: records requests, status configurable. function startSink() { const requests = []; let status = 200; const server = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { requests.push({ method: req.method, url: req.url, headers: req.headers, body }); res.statusCode = status; res.end(status === 200 ? 'ok' : 'err'); }); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { resolve({ url: `http://127.0.0.1:${server.address().port}`, requests, setStatus: (c) => { status = c; }, close: () => new Promise((r) => server.close(r)), }); }); }); } test('ntfy delivery: posts body + Title header; token stays in Authorization, not the body', async () => { const sink = await startSink(); try { await sendNtfy(sink.url, 'secret-token-xyz', 'Bill due', 'Electric $85 due tomorrow', 'overdue'); assert.equal(sink.requests.length, 1); const r = sink.requests[0]; assert.equal(r.method, 'POST'); assert.equal(r.body, 'Electric $85 due tomorrow'); assert.equal(r.headers['title'], 'Bill due'); assert.ok(r.headers['priority'], 'priority header present'); assert.equal(r.headers['authorization'], 'Bearer secret-token-xyz'); assert.ok(!r.body.includes('secret-token-xyz'), 'token must never appear in the message body'); } finally { await sink.close(); } }); test('gotify delivery: JSON {title, message, priority} to /message?token=', async () => { const sink = await startSink(); try { await sendGotify(sink.url, 'gtoken', 'Reminder', 'Rent due in 3 days', 'today'); const r = sink.requests[0]; assert.match(r.url, /\/message\?token=gtoken/); const json = JSON.parse(r.body); assert.equal(json.title, 'Reminder'); assert.equal(json.message, 'Rent due in 3 days'); assert.equal(json.priority, 7); // 'today' } finally { await sink.close(); } }); test('discord delivery: webhook embed with title/description', async () => { const sink = await startSink(); try { await sendDiscord(sink.url, 'Overdue bill', 'Water bill is 2 days overdue', 'overdue'); const json = JSON.parse(sink.requests[0].body); assert.equal(json.embeds[0].title, 'Overdue bill'); assert.equal(json.embeds[0].description, 'Water bill is 2 days overdue'); assert.ok(typeof json.embeds[0].color === 'number'); } finally { await sink.close(); } }); test('dispatch: sendPushToUser routes to the configured channel', async () => { const sink = await startSink(); try { const user = { notify_push_enabled: 1, push_channel: 'ntfy', push_url: sink.url, push_token: '' }; await sendPushToUser(user, 'Title', 'Body', 'soon'); assert.equal(sink.requests.length, 1); assert.equal(sink.requests[0].body, 'Body'); } finally { await sink.close(); } }); test('dispatch: disabled/unconfigured user sends nothing (no throw)', async () => { await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 0 }, 'T', 'B', 'soon')); await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 1, push_channel: 'ntfy' }, 'T', 'B', 'soon')); }); test('error handling: an unreachable/erroring channel throws a clear, channel-named error', async () => { const sink = await startSink(); sink.setStatus(500); try { await assert.rejects( sendNtfy(sink.url, '', 'T', 'B', 'soon'), /ntfy returned 500/, ); } finally { await sink.close(); } }); test('dispatch: an unknown channel throws instead of silently doing nothing', async () => { await assert.rejects( sendPushToUser({ notify_push_enabled: 1, push_channel: 'carrier-pigeon', push_url: 'http://x' }, 'T', 'B', 'soon'), /Unknown push channel: carrier-pigeon/, ); }); test('email HTML escapes the bill name everywhere — no XSS via the name (QA-B14-04)', () => { const { buildEmailHtml } = require('../services/notificationService')._email; const evil = ''; for (const type of ['due_3d', 'due_1d', 'due_today', 'overdue']) { const html = buildEmailHtml({ name: evil, expected_amount: 8500 }, type, '2026-07-15'); assert.ok(!html.includes(evil), `raw payload must not appear in the ${type} email HTML`); assert.ok(html.includes('<img src=x onerror='), `bill name must be HTML-escaped in the ${type} email (message + detail row)`); } });