109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
|
|
'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/,
|
||
|
|
);
|
||
|
|
});
|