BillTracker/tests/notificationDelivery.test.js

119 lines
4.8 KiB
JavaScript
Raw Normal View History

'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 = '<img src=x onerror=alert(1)>';
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('&lt;img src=x onerror='), `bill name must be HTML-escaped in the ${type} email (message + detail row)`);
}
});