diff --git a/.github/scripts/ci.sh b/.github/scripts/ci.sh
new file mode 100644
index 0000000..514be6a
--- /dev/null
+++ b/.github/scripts/ci.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+node --version
+npm install --unsafe-perm --no-audit --package-lock=false
+
+node <<'NODE'
+const fs = require('fs');
+const config = require('./srv_config.template.json');
+config.DB_HOST = '127.0.0.1';
+config.DB_PW = 'ci-password';
+fs.writeFileSync('srv_config.json', JSON.stringify(config));
+NODE
+
+node index.js > backend.log 2>&1 &
+backend_pid=$!
+cleanup() {
+ result=$?
+ if [ "$result" -ne 0 ]; then cat backend.log; fi
+ kill "$backend_pid" 2>/dev/null || true
+ exit "$result"
+}
+trap cleanup EXIT
+
+node <<'NODE'
+const http = require('http');
+const deadline = Date.now() + 60000;
+const check = () => {
+ const request = http.get('http://127.0.0.1:8000/settings', response => {
+ response.resume();
+ if (response.statusCode === 400) process.exit(0);
+ retry();
+ });
+ request.setTimeout(1000, () => request.destroy(new Error('Readiness request timed out')));
+ request.on('error', retry);
+};
+const retry = () => {
+ if (Date.now() >= deadline) {
+ console.error('Backend did not become ready within 60 seconds');
+ process.exit(1);
+ }
+ setTimeout(check, 500);
+};
+check();
+NODE
+
+# The MySQL service was initialized before this container started.
+node node_modules/mocha/bin/_mocha tests/*
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cd2b8b7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,41 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Node 8 / MySQL 5.7.34
+ runs-on: ubuntu-22.04
+ timeout-minutes: 20
+ services:
+ mysql:
+ image: mysql:5.7.34
+ env:
+ MYSQL_ROOT_PASSWORD: ci-password
+ MYSQL_DATABASE: evnotify_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -pci-password"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=30
+ steps:
+ - uses: actions/checkout@v4
+ - name: Initialize test database
+ env:
+ MYSQL_CONTAINER: ${{ job.services.mysql.id }}
+ run: |
+ docker exec "$MYSQL_CONTAINER" mysql -uroot -pci-password --execute='SELECT VERSION()'
+ docker exec -i "$MYSQL_CONTAINER" mysql -uroot -pci-password evnotify_test < modules/db/db_template.sql
+ - name: Run backend and tests on Node 8.17.0
+ run: |
+ docker run --rm --network host \
+ --volume "$GITHUB_WORKSPACE:/app" --workdir /app \
+ node:8.17.0 bash .github/scripts/ci.sh
diff --git a/.hound.yml b/.hound.yml
new file mode 100644
index 0000000..0508b09
--- /dev/null
+++ b/.hound.yml
@@ -0,0 +1,3 @@
+jshint:
+ enabled: true
+ config_file: .jshintrc
diff --git a/.jshintrc b/.jshintrc
index 0e574b6..ede9d2d 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -2,9 +2,12 @@
"undef": true,
"unused": true,
"esversion": 6,
+ "node": true,
+ "mocha": true,
+ "multistr": true,
"globals": {
"require": false,
"module": false,
"console": false
}
- }
\ No newline at end of file
+ }
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 0c6a687..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-language: node_js
-node_js: 8
-before_script:
- - npm run createEnv
- - node index.js &
- - sleep 30
-services:
- - mysql
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 885cfeb..ac70803 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,7 +28,7 @@ The complete source code is available open source here, but out of the box your
- You have to take care of keeping everything up-to-date - this means, your OS, packages and - the source code from this repository by itself. At the end I'll explain, how you can do this easily.
- You may need to have your own domain - and a HTTPS certificate running on it - to encrypt the traffic that receives and sends out of the server. It's possible to run the server in HTTP mode, but that's not recommended.
- Out of the box, you'll not be able to send notifications - to enable that feature, you'll need to provide some options in a config file that will be explained later. Keep in mind, that based on your needs, you need to register for some services, which may require some installation or other things (e.g. mail server, Telegram Bot Token, ..)
-- Out of the box, your server will not automatically track uncaught exceptions or analyze the usage. To be able to do so, you'll need to specify them in the config file, which will be explained later, as mentioned previously.
+- To track uncaught exceptions with Rollbar, configure `ROLLBAR_TOKEN` as described below.
#### First-time-setup
So, now we will setup the Backend for EVNotify. This step is required only once.
@@ -67,8 +67,6 @@ Here are optional keys, which can be specified within the _srv_config.json_ file
- `FIREBASE_URL` - url of firebase project
###### Rollbar (automated error tracking)
- `ROLLBAR_TOKEN` - the token of your Rollbar project
-###### Moesif (automated API traffic and usage analyzation)
-- `MOESIF_TOKEN` - the token of your Moesif project
###### GoingElectric (for stations finder)
- `GE_API_URL` - the URL to the GoingElectric API (currently _https://api.goingelectric.de_)
- `GE_API_KEY` - the key for the GoingElectric API (Format: `?key=XXXX`)
diff --git a/README.md b/README.md
index 60f2677..dec522d 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,74 @@
# EVNotifyBackend
[](https://greenkeeper.io/)
-[](https://travis-ci.org/EVNotify/EVNotifyBackend)
+[](https://github.com/EVNotify/EVNotifyBackend/actions/workflows/ci.yml)
-Backend for EVNotify (https://github.com/EVNotify/EVNotify)
+Backend for [EVNotify](https://github.com/EVNotify/EVNotify).
-### Contributing
-Feel free to contribute and report issues or suggestions.
-If you are a developer and want to contribute, have a look at the CONTRIBUTING.md file.
+## Features
-The App of EVNotify as well as the Backend has an integrated mechanism to track errors with a tool called Rollbar. This allows faster tracking of uncaught errors.
+- Account registration and authentication
+- Vehicle sync endpoints for SOC, location, and extended telemetry
+- Notifications via mail, push, and Telegram
+- Optional outbound webhooks for sync and notification events
+- Error tracking through Rollbar
-
+## Webhooks
+
+Webhooks are configured by an administrator on request, directly in the user's `settings.webhook` database column. `GET /settings` returns the configured URL. `PUT /settings` ignores any submitted `webhook` value and omits it from its response; users cannot change or disable delivery through the API. To disable delivery, an administrator sets the database value to `NULL` or an empty string.
+
+Stored webhook URLs must be absolute HTTPS URLs with a hostname and a valid port. HTTP and invalid URLs are logged and skipped before DNS lookup or delivery, without interrupting API requests. Administrators must replace existing HTTP URLs with HTTPS endpoints; stored values are not automatically changed.
+
+Destinations must resolve exclusively to public IPv4 or IPv6 addresses. Loopback, private, link-local (including cloud metadata), reserved, and other non-public addresses are blocked, including alternate IP spellings and IPv4-mapped IPv6 addresses containing blocked IPv4 addresses. DNS lookup failures or a lookup taking longer than 10 seconds skip delivery. Connections use a checked address without resolving again; proxies and redirects are disabled for all webhook requests. Blocked destinations are logged without changing the stored URL or interrupting API requests.
+
+The backend sends a JSON `POST` directly to the configured URL, including its path, after successful sync or notification requests:
+
+- `soc`
+- `location`
+- `extended`
+- `notification`
+
+The JSON payload contains:
+
+```json
+{
+ "akey": "ABC123",
+ "type": "soc",
+ "response": {}
+}
+```
+
+| Event type | `response` contents |
+| --- | --- |
+| `soc` | Submitted `display` and `bms` readings |
+| `location` | Submitted `location` object |
+| `extended` | Submitted telemetry body, with `token` removed |
+| `notification` | `soc_display`, `soc_bms`, and the submitted `abort` flag |
+
+The account's API `token` is included only as an outer payload field when the URL uses HTTPS and its hostname is exactly `evnotify.de`, `evnotify.com`, or a subdomain of either. Matching is case insensitive and requires a dot boundary: `api.evnotify.de` qualifies, but `fakeevnotify.de` and `evnotify.de.attacker.com` do not. Other destinations receive events without a token. Requests carrying tokens never follow redirects.
-For API Analytics and error tracking a tool called Moesif is used.
+Delivery happens in the background with a 10-second request timeout and no retries. Transport errors and non-2xx HTTP responses (including redirects) are logged. HTTP failure logs include the status code without URLs, credentials, payloads, or response bodies. A successful API response does not confirm webhook delivery. Rejected API requests and rate-limited notifications do not emit events.
-[Powered By Moesif API Analytics](https://www.moesif.com)
+## Development
-[](https://www.moesif.com)
+Install dependencies with `npm install`. GitHub Actions runs the full test suite in Docker using Node 8.17.0 and MySQL 5.7.34, with a disposable test database.
+
+Run the isolated settings, webhook, sync, and notification tests without a backend or database:
+
+```bash
+./node_modules/.bin/mocha tests/settings/index.js tests/webhook/index.js tests/sync/index.js tests/notification/index.js
+```
+
+The full suite also includes account integration tests, which require a running backend at `TEST_URL` (or the configured local port) and a local test database configured in `srv_config.json`. Its setup creates the database and imports the schema:
+
+```bash
+npm test
+```
+
+## Contributing
+
+Feel free to contribute and report issues or suggestions. If you are a developer and want to contribute, have a look at [CONTRIBUTING.md](CONTRIBUTING.md).
+
+The app and backend use Rollbar to track uncaught errors and speed up debugging.
+
+
diff --git a/index.js b/index.js
index d280683..88e3e2e 100644
--- a/index.js
+++ b/index.js
@@ -26,17 +26,6 @@ const express = require('express'),
environment: ((srv_config.DEBUG) ? 'development' : 'production'),
captureUnhandledRejections: true
}) : false),
- moesifExpress = require('moesif-express'),
- moesif = ((srv_config.MOESIF_TOKEN) ? moesifExpress({
- applicationId: srv_config.MOESIF_TOKEN,
- identifyUser: req => req.body.akey,
- getApiVersion: () => '2',
- skip: req => {
- const path = req.path.toLowerCase();
-
- return path === '/location' || path === '/debug' || path === '/soc' || path === '/extended'
- }
- }) : false),
db = require('./modules/db'),
account = require('./modules/account'),
sync = require('./modules/sync'),
@@ -97,9 +86,6 @@ app.use((req, res, next) => {
next();
});
-// moesif middleware
-if (moesif) app.use(moesif);
-
if (srv_config.ROOKOUT_TOKEN) rookout.start({token: srv_config.ROOKOUT_TOKEN});
// last activity track
diff --git a/modules/notification/index.js b/modules/notification/index.js
index 9da5858..3bbf4c1 100644
--- a/modules/notification/index.js
+++ b/modules/notification/index.js
@@ -49,7 +49,7 @@ const send = (req, res) => {
now, req.body.akey
]);
// trigger webhook
- webhook.emit(req.body.akey, 'notification', {
+ webhook.emit(req.body.akey, req.body.token, 'notification', {
soc_display: userObj.soc_display,
soc_bms: userObj.soc_bms,
abort: req.body.abort
diff --git a/modules/settings/index.js b/modules/settings/index.js
index 1400cc4..c6673e8 100644
--- a/modules/settings/index.js
+++ b/modules/settings/index.js
@@ -15,7 +15,7 @@ const srv_config = require('./../../srv_config.json'),
* @param {Function} callback callback function
*/
const getSettings = (akey, callback) => {
- db.query('SELECT email, telegram, abrp, push, soc, consumption, capacity, car, device, lng, summary FROM settings WHERE akey=?', [
+ db.query('SELECT email, telegram, abrp, push, soc, consumption, capacity, car, device, lng, summary, webhook FROM settings WHERE akey=?', [
akey
], (err, dbRes) => {
if (!err && dbRes && dbRes[0] && dbRes[0].email) dbRes[0].email = encryption.decrypt(dbRes[0].email);
@@ -30,7 +30,7 @@ const getSettings = (akey, callback) => {
* @param {Function} callback callback function
*/
const setSettings = (akey, settings, callback) => {
- db.query('UPDATE settings SET email=?, telegram=?, push=?, soc=?, consumption=?, capacity=?, car=?, device=?, lng=?, summary=? WHERE akey=?', [
+ const values = [
((settings.email) ? encryption.encrypt(settings.email) : ''),
settings.telegram,
settings.push,
@@ -42,7 +42,10 @@ const setSettings = (akey, settings, callback) => {
settings.lng,
settings.summary,
akey
- ], (err, dbRes) => callback(err, ((!err && dbRes && dbRes[0]) ? dbRes[0] : null)));
+ ];
+
+ db.query('UPDATE settings SET email=?, telegram=?, push=?, soc=?, consumption=?, capacity=?, car=?, device=?, lng=?, summary=? WHERE akey=?', values,
+ (err, dbRes) => callback(err, ((!err && dbRes && dbRes[0]) ? dbRes[0] : null)));
};
module.exports = {
@@ -106,10 +109,12 @@ module.exports = {
if (!err) {
if (valid) {
// set settings
- setSettings(req.body.akey, req.body.settings, (err, settings) => {
+ setSettings(req.body.akey, req.body.settings, err => {
if (!err) {
+ const updatedSettings = Object.assign({}, req.body.settings);
+ delete updatedSettings.webhook;
res.json({
- settings: req.body.settings
+ settings: updatedSettings
});
} else {
res.status(422).json({
diff --git a/modules/sync/index.js b/modules/sync/index.js
index a244da0..042e405 100644
--- a/modules/sync/index.js
+++ b/modules/sync/index.js
@@ -7,6 +7,7 @@ const srv_config = require('./../../srv_config.json'),
srv_errors = require('./../../srv_errors.json'),
db = require('./../db'),
abrp = require('./../integrations/abrp'),
+ webhook = require('./../webhook'),
token = require('./../token');
const updatedRecently = (now, dbTimestamp) => now - 5 < dbTimestamp;
@@ -171,6 +172,10 @@ module.exports = {
bms: req.body.bms
}, (err, synced) => {
if (!err && synced) {
+ webhook.emit(req.body.akey, req.body.token, 'soc', {
+ display: req.body.display,
+ bms: req.body.bms
+ });
res.json({
synced: true
});
@@ -248,6 +253,7 @@ module.exports = {
if (valid) {
postExtended(req.body.akey, req.body, (err, synced) => {
if (!err && synced) {
+ webhook.emit(req.body.akey, req.body.token, 'extended', req.body);
res.json({
synced
});
@@ -314,7 +320,7 @@ module.exports = {
*/
postLocation: (req, res) => {
// check required params
- if (!req.body.akey || !req.body.token || !req.body.location == null || typeof req.body.location !== 'object') {
+ if (!req.body.akey || !req.body.token || req.body.location == null || typeof req.body.location !== 'object') {
return res.status(400).json({
error: srv_errors.INVALID_PARAMETERS
});
@@ -325,6 +331,7 @@ module.exports = {
if (valid) {
postLocation(req.body.akey, req.body.location, (err, synced) => {
if (!err && synced) {
+ webhook.emit(req.body.akey, req.body.token, 'location', req.body.location);
res.json({
synced: true
});
diff --git a/modules/webhook/index.js b/modules/webhook/index.js
index ce886dd..78f9e88 100644
--- a/modules/webhook/index.js
+++ b/modules/webhook/index.js
@@ -5,32 +5,149 @@
*/
const request = require('request');
const db = require('./../db');
+const dns = require('dns');
+const net = require('net');
+const timers = require('timers');
+const ipaddr = require('ipaddr.js');
+// Match the parser used by request so validation and delivery agree on the host.
+const parseUrl = require('url').parse;
+const URL = require('url').URL;
+
+const validateUrl = uri => {
+ try {
+ if (typeof uri !== 'string' || !/^https:\/\//i.test(uri) || /\s/.test(uri)) return null;
+ // Check syntax and port bounds, then use request's parser for host policy.
+ const url = parseUrl(new URL(uri).href);
+ if (url.protocol !== 'https:' || !url.hostname || url.hostname === 'unix') return null;
+ return url;
+ } catch (err) {
+ return null;
+ }
+};
+
+const isPublicAddress = address => {
+ try {
+ if (!net.isIP(address) || address.includes('%')) return false;
+ const ip = ipaddr.process(address);
+ if (ip.range() !== 'unicast') return false;
+ if (ip.kind() === 'ipv4') {
+ return !ip.match(ipaddr.parseCIDR('198.18.0.0/15'));
+ }
+ // Only global IPv6 unicast, excluding special-purpose and documentation ranges.
+ return ip.match(ipaddr.parseCIDR('2000::/3')) &&
+ !ip.match(ipaddr.parseCIDR('2001::/23')) &&
+ !ip.match(ipaddr.parseCIDR('3fff::/20'));
+ } catch (err) {
+ return false;
+ }
+};
+
+const resolveDestination = (hostname, callback) => {
+ if (net.isIP(hostname)) {
+ return callback(isPublicAddress(hostname) ? null : new Error('Blocked webhook destination'),
+ { address: hostname, family: net.isIP(hostname) });
+ }
+
+ let finished = false;
+ const finish = (err, address) => {
+ if (finished) return;
+ finished = true;
+ timers.clearTimeout(timeout);
+ callback(err, address);
+ };
+ const timeout = timers.setTimeout(() => finish(new Error('Webhook DNS lookup timed out')), 10000);
+ try {
+ dns.lookup(hostname, { all: true }, (err, addresses) => {
+ if (err || !Array.isArray(addresses) || !addresses.length) {
+ return finish(new Error('Webhook DNS lookup failed'));
+ }
+ if (!addresses.every(entry => entry && isPublicAddress(entry.address) &&
+ net.isIP(entry.address) === entry.family)) {
+ return finish(new Error('Blocked webhook destination'));
+ }
+ finish(null, addresses[0]);
+ });
+ } catch (err) {
+ finish(new Error('Webhook DNS lookup failed'));
+ }
+};
+
+const canSendToken = uri => {
+ try {
+ const url = parseUrl(uri);
+ return url.protocol === 'https:' && typeof url.hostname === 'string' &&
+ ['evnotify.de', 'evnotify.com'].some(domain =>
+ url.hostname === domain || url.hostname.endsWith('.' + domain));
+ } catch (err) {
+ return false;
+ }
+};
module.exports = {
/**
* Sends Event to webhook url if specified for given type and response
*/
- emit: (akey, type, response) => {
+ emit: (akey, token, type, response) => {
db.query('SELECT webhook FROM settings WHERE akey=?', [
akey
], (err, dbRes) => {
if (!err && dbRes && dbRes[0] && dbRes[0].webhook) {
- request({
- uri: dbRes[0].webhook + '/webhook',
- method: 'POST',
- json: true,
- timeout: 10000,
- followRedirect: true,
- body: {
- akey,
- type,
- response
- },
- maxRedirects: 10
- }, err => {
- if (err) console.error(err);
+ const url = validateUrl(dbRes[0].webhook);
+ if (!url) {
+ console.error(new Error('Invalid webhook URL; delivery skipped'));
+ return;
+ }
+ const sendToken = canSendToken(url.href);
+ const body = { akey, type, response };
+
+ // Extended telemetry includes the incoming credentials. Never forward
+ // its token implicitly or mutate the original request body.
+ if (response && typeof response === 'object') {
+ body.response = Array.isArray(response) ? response.slice() : Object.assign({}, response);
+ delete body.response.token;
+ }
+ if (sendToken) body.token = token;
+
+ resolveDestination(url.hostname, (err, destination) => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+ // Reuse exactly the checked address; never resolve again at connection time.
+ const lookup = (hostname, options, callback) => {
+ if (typeof options === 'function') {
+ callback = options;
+ options = {};
+ }
+ if (hostname !== url.hostname) return callback(new Error('Unexpected webhook hostname'));
+ if (options && options.all) return callback(null, [destination]);
+ callback(null, destination.address, destination.family);
+ };
+ try {
+ request({
+ uri: url.href,
+ method: 'POST',
+ json: true,
+ timeout: 10000,
+ followRedirect: false,
+ followAllRedirects: false,
+ proxy: null,
+ pool: false,
+ lookup,
+ family: destination.family,
+ body
+ }, (err, response) => {
+ if (err) {
+ console.error(new Error('Webhook delivery failed'));
+ } else if (response && (response.statusCode < 200 || response.statusCode >= 300)) {
+ console.error(new Error('Webhook delivery failed: HTTP ' + response.statusCode));
+ }
+ });
+ } catch (err) {
+ console.error(new Error('Webhook delivery failed'));
+ }
});
}
});
}
-};
\ No newline at end of file
+};
diff --git a/package.json b/package.json
index 6967d81..98885dc 100644
--- a/package.json
+++ b/package.json
@@ -14,8 +14,8 @@
"express": "4.17.1",
"express-session": "1.16.2",
"firebase-admin": "8.4.0",
+ "ipaddr.js": "1.9.1",
"mocha": "6.0.0",
- "moesif-express": "2.9.7",
"mysql": "2.17.0",
"node-telegram-bot-api": "0.30.0",
"nodemailer": "5.1.1",
diff --git a/tests/helpers.js b/tests/helpers.js
new file mode 100644
index 0000000..d5f0f55
--- /dev/null
+++ b/tests/helpers.js
@@ -0,0 +1,34 @@
+const path = require('path');
+
+// Restore every dependency and target cache entry, including after load failures.
+const loadModule = (target, stubs) => {
+ const resolve = name => require.resolve(name.startsWith('.') ? path.resolve(__dirname, '..', name) : name);
+ const targetPath = resolve(target);
+ const originals = new Map([[targetPath, require.cache[targetPath]]]);
+ Object.keys(stubs).forEach(name => {
+ const filename = resolve(name);
+ originals.set(filename, require.cache[filename]);
+ require.cache[filename] = { id: filename, filename, loaded: true, exports: stubs[name] };
+ });
+ const restore = () => originals.forEach((original, filename) => {
+ if (original) require.cache[filename] = original;
+ else delete require.cache[filename];
+ });
+ delete require.cache[targetPath];
+ try {
+ return { module: require(targetPath), restore };
+ } catch (err) {
+ restore();
+ throw err;
+ }
+};
+
+const createResponse = () => ({
+ statusCode: 200,
+ headers: {},
+ status(code) { this.statusCode = code; return this; },
+ setHeader(name, value) { this.headers[name] = value; },
+ json(payload) { this.body = payload; return this; }
+});
+
+module.exports = { loadModule, createResponse };
diff --git a/tests/notification/index.js b/tests/notification/index.js
new file mode 100644
index 0000000..0bd3c2c
--- /dev/null
+++ b/tests/notification/index.js
@@ -0,0 +1,90 @@
+const assert = require('assert');
+const { loadModule, createResponse } = require('../helpers');
+const errors = require('../../srv_errors.json');
+
+describe('notification webhook events', () => {
+ let loader;
+ let events;
+ let queries;
+ let user;
+ let dbError;
+
+ beforeEach(() => {
+ events = [];
+ queries = [];
+ dbError = null;
+ user = { akey: 'ABC123', token: 'secret-token', soc_display: 80, soc_bms: 78, last_notification: 0 };
+ const unusedChannel = () => { throw new Error('disabled notification channel called'); };
+ loader = loadModule('./modules/notification', {
+ './modules/db': {
+ query: (sql, params, callback) => {
+ queries.push({ sql, params });
+ if (callback) callback(dbError, user ? [user] : []);
+ }
+ },
+ './modules/webhook': { emit: (...args) => events.push(args) },
+ './modules/notification/mail': { sendMail: unusedChannel },
+ './modules/notification/telegram': { sendMessage: unusedChannel },
+ './modules/notification/push': { sendPush: unusedChannel }
+ });
+ });
+
+ afterEach(() => loader.restore());
+
+ const invoke = body => {
+ const res = createResponse();
+ loader.module.send({ body: body || { akey: 'ABC123', token: 'secret-token', abort: false } }, res);
+ return res;
+ };
+
+ [false, true].forEach(abort => {
+ it('emits the SOC and abort flag for a successful notification (abort=' + abort + ')', () => {
+ const res = invoke({ akey: 'ABC123', token: 'secret-token', abort });
+ assert.strictEqual(res.statusCode, 200);
+ assert.deepStrictEqual(res.body, { notified: true });
+ assert.deepStrictEqual(events, [[
+ 'ABC123', 'secret-token', 'notification', { soc_display: 80, soc_bms: 78, abort }
+ ]]);
+ assert.strictEqual(queries.length, 2);
+ assert.ok(queries[1].sql.startsWith('UPDATE sync SET last_notification='));
+ assert.strictEqual(queries[1].params[1], 'ABC123');
+ });
+ });
+
+ it('does not emit when credentials are missing', () => {
+ const res = invoke({ akey: 'ABC123' });
+ assert.strictEqual(res.statusCode, 400);
+ assert.strictEqual(queries.length, 0);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it('does not emit when the token is invalid', () => {
+ user.token = 'different-token';
+ assert.strictEqual(invoke().statusCode, 401);
+ assert.strictEqual(queries.length, 1);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it('does not emit when rate limited and returns Retry-After', () => {
+ user.last_notification = Math.floor(Date.now() / 1000) + 60;
+ const res = invoke();
+ assert.strictEqual(res.statusCode, 429);
+ assert.deepStrictEqual(res.body.error, errors.TOO_MANY_REQUESTS);
+ assert.strictEqual(res.headers['Retry-After'], 60);
+ assert.strictEqual(queries.length, 1);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it('does not emit when the account lookup fails', () => {
+ dbError = new Error('database unavailable');
+ assert.strictEqual(invoke().statusCode, 422);
+ assert.strictEqual(queries.length, 1);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it('does not emit when the account does not exist', () => {
+ user = null;
+ assert.strictEqual(invoke().statusCode, 422);
+ assert.deepStrictEqual(events, []);
+ });
+});
diff --git a/tests/settings/index.js b/tests/settings/index.js
new file mode 100644
index 0000000..78d7ece
--- /dev/null
+++ b/tests/settings/index.js
@@ -0,0 +1,143 @@
+const assert = require('assert');
+const { loadModule, createResponse } = require('../helpers');
+const errors = require('../../srv_errors.json');
+
+describe('settings module', () => {
+ let loader;
+ let queries;
+ let dbError;
+ let authError;
+ let valid;
+ let storedWebhook;
+
+ beforeEach(() => {
+ queries = [];
+ dbError = null;
+ authError = null;
+ valid = true;
+ storedWebhook = 'https://existing.example/hook';
+ loader = loadModule('./modules/settings', {
+ './modules/db': {
+ query: (sql, params, callback) => {
+ queries.push({ sql, params });
+ assert.strictEqual((sql.match(/\?/g) || []).length, params.length);
+ if (sql.startsWith('SELECT')) {
+ assert.deepStrictEqual(params, ['ABC123']);
+ callback(dbError, [{ email: 'enc:mail@example.com', webhook: storedWebhook }]);
+ } else {
+ assert.strictEqual(params[params.length - 1], 'ABC123');
+ if (!dbError && sql.includes('webhook=?')) storedWebhook = params[params.length - 2];
+ callback(dbError, { affectedRows: 1 });
+ }
+ }
+ },
+ './modules/token': {
+ validateToken: (akey, token, callback) => {
+ assert.strictEqual(akey, 'ABC123');
+ assert.strictEqual(token, 'secret-token');
+ callback(authError, valid);
+ }
+ },
+ './modules/encryption': {
+ encrypt: value => 'enc:' + value,
+ decrypt: value => value.slice(4)
+ }
+ });
+ });
+
+ afterEach(() => loader.restore());
+
+ const request = settings => ({
+ body: { akey: 'ABC123', token: 'secret-token', settings },
+ query: { akey: 'ABC123', token: 'secret-token' }
+ });
+
+ it('reads the webhook setting and decrypts email', () => {
+ const res = createResponse();
+ loader.module.getSettings(request(), res);
+ assert.strictEqual(res.statusCode, 200);
+ assert.ok(queries[0].sql.includes('webhook'));
+ assert.deepStrictEqual(res.body.settings, {
+ email: 'mail@example.com', webhook: storedWebhook
+ });
+ });
+
+ ['https://new.example/hook', null, ''].forEach(value => {
+ it('ignores submitted webhook ' + JSON.stringify(value) + ' while saving other settings', () => {
+ const settings = Object.freeze({ email: 'mail@example.com', soc: 80, webhook: value });
+ const res = createResponse();
+ loader.module.setSettings(request(settings), res);
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(storedWebhook, 'https://existing.example/hook');
+ assert.strictEqual(queries.length, 1);
+ assert.ok(!queries[0].sql.includes('webhook'));
+ assert.strictEqual(queries[0].params[0], 'enc:mail@example.com');
+ assert.strictEqual(queries[0].params[3], 80);
+ assert.deepStrictEqual(res.body, { settings: { email: 'mail@example.com', soc: 80 } });
+ assert.strictEqual(settings.webhook, value);
+
+ const getRes = createResponse();
+ loader.module.getSettings(request(), getRes);
+ assert.strictEqual(getRes.statusCode, 200);
+ assert.strictEqual(getRes.body.settings.webhook, 'https://existing.example/hook');
+ });
+ });
+
+ it('preserves the existing URL when webhook is omitted, without an extra query', () => {
+ const res = createResponse();
+ loader.module.setSettings(request({ email: 'mail@example.com' }), res);
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(storedWebhook, 'https://existing.example/hook');
+ assert.strictEqual(queries.length, 1);
+ assert.ok(!queries[0].sql.includes('webhook'));
+ assert.deepStrictEqual(res.body, { settings: { email: 'mail@example.com' } });
+ });
+
+ [undefined, null, 'invalid'].forEach(settings => {
+ it('rejects invalid settings payload ' + JSON.stringify(settings), () => {
+ const res = createResponse();
+ loader.module.setSettings(request(settings), res);
+ assert.strictEqual(res.statusCode, 400);
+ assert.deepStrictEqual(res.body.error, errors.INVALID_PARAMETERS);
+ assert.strictEqual(queries.length, 0);
+ });
+ });
+
+ ['getSettings', 'setSettings'].forEach(handler => {
+ it(handler + ' rejects missing credentials without querying the database', () => {
+ const res = createResponse();
+ const req = request({ webhook: 'https://new.example/hook' });
+ delete req.body.token;
+ delete req.query.token;
+ loader.module[handler](req, res);
+ assert.strictEqual(res.statusCode, 400);
+ assert.strictEqual(queries.length, 0);
+ });
+
+ it(handler + ' rejects invalid authentication without querying settings', () => {
+ valid = false;
+ const res = createResponse();
+ loader.module[handler](request({ webhook: 'https://new.example/hook' }), res);
+ assert.strictEqual(res.statusCode, 401);
+ assert.deepStrictEqual(res.body.error, errors.INVALID_TOKEN);
+ assert.strictEqual(queries.length, 0);
+ });
+
+ it(handler + ' handles authentication lookup failure', () => {
+ authError = new Error('authentication lookup failed');
+ const res = createResponse();
+ loader.module[handler](request({}), res);
+ assert.strictEqual(res.statusCode, 422);
+ assert.strictEqual(queries.length, 0);
+ });
+
+ it(handler + ' reports database failures without changing the stored URL', () => {
+ dbError = new Error('database unavailable');
+ const res = createResponse();
+ loader.module[handler](request({ webhook: 'https://new.example/hook' }), res);
+ assert.strictEqual(res.statusCode, 422);
+ assert.deepStrictEqual(res.body.error, errors.UNPROCESSABLE_ENTITY);
+ assert.strictEqual(storedWebhook, 'https://existing.example/hook');
+ });
+ });
+});
diff --git a/tests/sync/index.js b/tests/sync/index.js
new file mode 100644
index 0000000..78a6733
--- /dev/null
+++ b/tests/sync/index.js
@@ -0,0 +1,140 @@
+const assert = require('assert');
+const { loadModule, createResponse } = require('../helpers');
+const errors = require('../../srv_errors.json');
+
+const cases = [
+ { handler: 'postSoC', type: 'soc', data: { display: 80, bms: 78 }, payload: { display: 80, bms: 78 } },
+ { handler: 'postExtended', type: 'extended', data: { soh: 98, charging: 1 } },
+ {
+ handler: 'postLocation', type: 'location',
+ data: { location: { latitude: 50, longitude: 8, speed: 20, accuracy: 5, timestamp: 123 } },
+ payload: { latitude: 50, longitude: 8, speed: 20, accuracy: 5, timestamp: 123 }
+ }
+];
+
+describe('sync webhook events', () => {
+ let loader;
+ let events;
+ let queries;
+ let valid;
+ let authError;
+ let failedStage;
+ let recent;
+
+ beforeEach(() => {
+ events = [];
+ queries = [];
+ valid = true;
+ authError = null;
+ failedStage = null;
+ recent = false;
+ loader = loadModule('./modules/sync', {
+ './modules/db': {
+ query: (sql, params, callback) => {
+ queries.push(sql);
+ const stage = sql.split(' ')[0];
+ if (stage === failedStage) return callback(new Error('database failure'));
+ if (stage === 'SELECT') {
+ const timestamp = recent ? Math.floor(Date.now() / 1000) + 60 : 0;
+ return callback(null, [{ last_soc: timestamp, last_extended: timestamp, last_location: timestamp }]);
+ }
+ callback(null, { affectedRows: 1 });
+ }
+ },
+ './modules/token': { validateToken: (akey, token, callback) => {
+ assert.strictEqual(akey, 'ABC123');
+ assert.strictEqual(token, 'secret-token');
+ callback(authError, valid);
+ } },
+ './modules/webhook': { emit: (...args) => events.push(args) },
+ './modules/integrations/abrp': { submitData: () => {} }
+ });
+ });
+
+ afterEach(() => loader.restore());
+
+ cases.forEach(testCase => {
+ const request = () => ({ body: Object.assign({ akey: 'ABC123', token: 'secret-token' }, testCase.data) });
+ const invoke = req => {
+ const res = createResponse();
+ loader.module[testCase.handler](req || request(), res);
+ return res;
+ };
+
+ it(testCase.handler + ' emits exactly one event after successful writes', () => {
+ const req = request();
+ const res = invoke(req);
+ assert.strictEqual(res.statusCode, 200);
+ assert.ok(res.body.synced);
+ assert.strictEqual(queries.length, 3);
+ assert.deepStrictEqual(events, [[
+ 'ABC123', 'secret-token', testCase.type, testCase.payload || req.body
+ ]]);
+ });
+
+ it(testCase.handler + ' still emits when recent updates skip the statistics insert', () => {
+ recent = true;
+ const res = invoke();
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(queries.length, 2);
+ assert.strictEqual(events.length, 1);
+ });
+
+ it(testCase.handler + ' rejects missing credentials without emitting', () => {
+ const req = request();
+ delete req.body.token;
+ const res = invoke(req);
+ assert.strictEqual(res.statusCode, 400);
+ assert.deepStrictEqual(res.body.error, errors.INVALID_PARAMETERS);
+ assert.strictEqual(queries.length, 0);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it(testCase.handler + ' rejects invalid authentication without emitting', () => {
+ valid = false;
+ const res = invoke();
+ assert.strictEqual(res.statusCode, 401);
+ assert.strictEqual(queries.length, 0);
+ assert.deepStrictEqual(events, []);
+ });
+
+ it(testCase.handler + ' handles authentication lookup errors without emitting', () => {
+ authError = new Error('authentication lookup failed');
+ assert.strictEqual(invoke().statusCode, 422);
+ assert.strictEqual(queries.length, 0);
+ assert.deepStrictEqual(events, []);
+ });
+
+ ['SELECT', 'UPDATE', 'INSERT'].forEach(stage => {
+ it(testCase.handler + ' does not emit when the ' + stage + ' fails', () => {
+ failedStage = stage;
+ const res = invoke();
+ assert.strictEqual(res.statusCode, 422);
+ assert.deepStrictEqual(res.body.error, errors.UNPROCESSABLE_ENTITY);
+ assert.deepStrictEqual(events, []);
+ });
+ });
+ });
+
+ it('rejects SOC without either reading and location with an invalid payload', () => {
+ const res = createResponse();
+ loader.module.postSoC({ body: { akey: 'ABC123', token: 'secret-token' } }, res);
+ assert.strictEqual(res.statusCode, 400);
+ const locationRes = createResponse();
+ loader.module.postLocation({ body: { akey: 'ABC123', token: 'secret-token', location: 'invalid' } }, locationRes);
+ assert.strictEqual(locationRes.statusCode, 400);
+ assert.deepStrictEqual(events, []);
+ assert.strictEqual(queries.length, 0);
+ });
+
+ [undefined, null].forEach(location => {
+ it('rejects ' + location + ' location before accessing its properties', () => {
+ const res = createResponse();
+ loader.module.postLocation({ body: { akey: 'ABC123', token: 'secret-token', location } }, res);
+ assert.strictEqual(res.statusCode, 400);
+ assert.deepStrictEqual(res.body.error, errors.INVALID_PARAMETERS);
+ assert.strictEqual(queries.length, 0);
+ assert.deepStrictEqual(events, []);
+ });
+ });
+});
diff --git a/tests/webhook/fixtures/server-cert.pem b/tests/webhook/fixtures/server-cert.pem
new file mode 100644
index 0000000..4569125
--- /dev/null
+++ b/tests/webhook/fixtures/server-cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDKjCCAhKgAwIBAgIUYLCMJ8IT8U1PgnGAuPADAFUIHaIwDQYJKoZIhvcNAQEL
+BQAwFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MCAXDTI2MTAxNzIwNDUzNFoYDzIx
+MjYwOTIzMjA0NTM0WjAXMRUwEwYDVQQDDAxleGFtcGxlLnRlc3QwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7eAPpc91zlymE82/AAzmtDEBiuFWqScFf
+uFJMDsWvTZU1Ym+nmlnCmSxXKDCHSguyUdsGmWbgcjLZ15tSICiuFDRacVumB/ys
+ofNoS3RYEaqoX+IR2K30mJCVPc3OFiVh7aEBK71nf9OeifO5V560LMWpQdwKA/Wx
+vws+vQrXDnwexkHN1Qeo/76WWbbWWTGrox874dICF+vlzKMvYv3e7p5c3PQL+yts
+q0PRsDrHup3t1kA3uEVGZ6TudSSFdmfICjzspfjx3mlYa7hDGWlPZrLyjKnCxIwo
+/TzLZMm5fZieDhFn0+u5DkdP5FVFbqmPIwfF0SXMuTTn6GdSyKejAgMBAAGjbDBq
+MB0GA1UdDgQWBBSRzyTnPHgwXgvWGtDzW+Mva98XVDAfBgNVHSMEGDAWgBSRzyTn
+PHgwXgvWGtDzW+Mva98XVDAPBgNVHRMBAf8EBTADAQH/MBcGA1UdEQQQMA6CDGV4
+YW1wbGUudGVzdDANBgkqhkiG9w0BAQsFAAOCAQEAjf4xopNpBZY+TrKqYblSTctw
+0v/O+JON5KctKgczjbqMWJ5AKAzAysWqfPqmI8rgQPE3bn2nklVw8ik/vmJQzjzL
+wj9M+TZ5CW4QmEhqp7W9eJj5DoaZ7UiCQDgU3OhyF4Oxma9iB3CUjSTsQBPdI/At
+1ITrWK4iV+Hu5YR7kPNGnc48wLt3xqUPAUkFYuY/2QvbmPnw6ylDPbG2G6/qOU/2
+Gzwk5+ttqB566XRIgoJYiW9ngDkrOZrD1J9OOCX9ok6mWCjgzSYZWocuiIdtek3J
+A8/5U2NUrU+MeWUCMDOfYt+nX6SHMnA1NA41jE70tDFkHKldY8l9i9iEDXZCbg==
+-----END CERTIFICATE-----
diff --git a/tests/webhook/fixtures/server-key.pem b/tests/webhook/fixtures/server-key.pem
new file mode 100644
index 0000000..7211176
--- /dev/null
+++ b/tests/webhook/fixtures/server-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7eAPpc91zlymE
+82/AAzmtDEBiuFWqScFfuFJMDsWvTZU1Ym+nmlnCmSxXKDCHSguyUdsGmWbgcjLZ
+15tSICiuFDRacVumB/ysofNoS3RYEaqoX+IR2K30mJCVPc3OFiVh7aEBK71nf9Oe
+ifO5V560LMWpQdwKA/Wxvws+vQrXDnwexkHN1Qeo/76WWbbWWTGrox874dICF+vl
+zKMvYv3e7p5c3PQL+ytsq0PRsDrHup3t1kA3uEVGZ6TudSSFdmfICjzspfjx3mlY
+a7hDGWlPZrLyjKnCxIwo/TzLZMm5fZieDhFn0+u5DkdP5FVFbqmPIwfF0SXMuTTn
+6GdSyKejAgMBAAECggEABeHe5RvDxn48euPfd9hQSQooNQmtWKx7rMoOGa9owRo/
+fc9HS0ja2yrrIFBRr7idywGNhVSn6Tqt4T039SjnuIjWUbRow0hQ/IZl8auqNy1x
+2HcAaJNH59dEHwZBmVEfzJsAmghNtWpJ4v07CWGbuirMXaKEO544FBviALlZeeSG
+eJMu0VqpvdyZVnK5UeTh5q+rQ9hF9+3saI4kWiSkW2lcTiQc2X1tY9rPUON7wdA5
+VGTJlpvX6/4eI6N/gTOit7XWsaS1w6TP8E3RKrVGg1BuVabnu13psTIG50na8U7F
+XLcFcMLOKvCg/gWiUKIDmi5WNBwLgauZXHyXBmCAoQKBgQDzMNGCUWjf+0cvAi3s
+4UctR0lCRxO/RpIQRLok6Dy6803ghhNWIL5iFlTXJ0XBkBjj3i2CofB9rPU2pz99
+Ydjtw2bLOyIoeGfRBxCsb0ZPoTlHxqfQWIP+L32csojYlXzG+mD0waA1pj9LadE3
+uxNc+nY0lj2KizrD344Z2sPzYQKBgQDFV9fmgIv/qnEZd2f7JpliVPtKkzFs+DDP
+VcGiedhmVo02DD0VI9NBLBjETYCLoe9OzF8bX6SCtuzrZLJW6jxEUBtlMr9fpHVI
+1IsTSWtp//lweQNrTCDYAUsbHcPaHgrm9qgM+9IQhsaOBHgXGJLAPgmgPA6RRRO/
+5naDEqU9gwKBgBuLeP94txc/f9WMOqVs8sHnJB4UrODqXlrnXeoWP+Kw8i4vIXuM
+4+N6QgvIj/cXmiodHSyBqRqi/3c8RE2wSoDLtBXq0bihwqiDU1kklWMpkLJXhuRr
+Ui/1MxYPhr55WBO1gkvWro3FEcA58Q+N6eiLGd5k8f2CPZq39IFrLOyhAoGBAIz4
+k2I1scyRFPn8MmtWx3tW+avsOgJ9yWBTAl/uaoewjG9vjrLrRJ3pwkd4lbspkqnX
+lJkZWYuG4PfGlx8/dI3YK7m+5raL5LyCGFdOu0ydbrLJv4eGem/TfLOCL8ZAb8lN
+qf6cZRMpM+1/g8n749kwd+/e9wkPL2yGVWKMjz3BAoGBANkV8LNIceeZW+QmvD3F
+ukOuL432M8XfSU4C1Z4qN6mLseOz9bsFf7WpnRUpMZRqCjhoHllnR8AC/UcQ6p9P
+O7CGduZOlf/Omm6Vx3z+clZmxO9lRJNGr32rYjfmH1/fHg39oaJuRyQrOG7CJg9C
+XAacSW+mSugv5Yataw1b7gE6
+-----END PRIVATE KEY-----
diff --git a/tests/webhook/index.js b/tests/webhook/index.js
new file mode 100644
index 0000000..32d61af
--- /dev/null
+++ b/tests/webhook/index.js
@@ -0,0 +1,455 @@
+const assert = require('assert');
+const { loadModule } = require('../helpers');
+const realRequest = require('request');
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+describe('webhook module', () => {
+ let loader;
+ let requests;
+ let destination;
+ let rows;
+ let dbError;
+ let transportError;
+ let transportResponse;
+ let synchronousError;
+ let loggedErrors;
+ let originalConsoleError;
+ let dnsAddresses;
+ let dnsError;
+ let dnsQueries;
+ let deferredLookup;
+ let dnsCallback;
+ let timeoutCallback;
+ let timeoutCleared;
+
+ beforeEach(() => {
+ requests = [];
+ destination = 'https://example.test/evnotify';
+ rows = undefined;
+ dbError = null;
+ transportError = null;
+ transportResponse = { statusCode: 200 };
+ synchronousError = null;
+ dnsAddresses = [{ address: '8.8.8.8', family: 4 }];
+ dnsError = null;
+ dnsQueries = [];
+ deferredLookup = false;
+ timeoutCleared = false;
+ timeoutCallback = null;
+ loggedErrors = [];
+ originalConsoleError = console.error;
+ console.error = err => loggedErrors.push(err);
+ loader = loadModule('./modules/webhook', {
+ dns: { lookup: (hostname, options, callback) => {
+ dnsQueries.push(hostname);
+ assert.deepStrictEqual(options, { all: true });
+ dnsCallback = callback;
+ if (!deferredLookup) callback(dnsError, dnsAddresses);
+ } },
+ timers: {
+ setTimeout: (callback, delay) => {
+ assert.strictEqual(delay, 10000);
+ timeoutCallback = callback;
+ return 1;
+ },
+ clearTimeout: () => { timeoutCleared = true; }
+ },
+ './modules/db': {
+ query: (sql, params, callback) => {
+ assert.ok(sql.includes('SELECT webhook FROM settings'));
+ assert.deepStrictEqual(params, ['ABC123']);
+ callback(dbError, rows === undefined ? [{ webhook: destination }] : rows);
+ }
+ },
+ request: (options, callback) => {
+ if (synchronousError) throw synchronousError;
+ requests.push(options);
+ callback(transportError, transportResponse);
+ }
+ });
+ });
+
+ afterEach(() => {
+ console.error = originalConsoleError;
+ if (loader) loader.restore();
+ });
+
+ const emit = response => loader.module.emit('ABC123', 'secret-token', 'extended', response);
+
+ [
+ '0.0.0.0', '127.0.0.1', '10.0.0.1', '172.16.0.1', '172.31.255.255',
+ '192.168.1.1', '169.254.169.254', '100.100.100.200', '198.18.0.1',
+ '192.0.2.1', '198.51.100.1', '203.0.113.1', '224.0.0.1', '240.0.0.1',
+ '255.255.255.255', '127.1', '2130706433', '0x7f000001', '0177.0.0.1',
+ '[::]', '[::1]', '[fc00::1]', '[fd00::1]', '[fe80::1]', '[ff02::1]',
+ '[::ffff:127.0.0.1]', '[::ffff:10.0.0.1]', '[::ffff:169.254.169.254]',
+ '[64:ff9b::a00:1]', '[2002:0a00:0001::1]', '[2001::1]',
+ '[2001:db8::1]', '[3fff::1]', '[4000::1]'
+ ].forEach(host => {
+ it('blocks non-public literal address ' + host, () => {
+ destination = 'https://' + host + '/hook';
+ assert.doesNotThrow(() => emit({}));
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(dnsQueries.length, 0);
+ assert.strictEqual(loggedErrors.length, 1);
+ });
+ });
+
+ ['8.8.8.8', '[2606:4700:4700::1111]', '[::ffff:8.8.8.8]'].forEach(host => {
+ it('allows public literal address ' + host, () => {
+ destination = 'https://' + host + '/hook';
+ emit({});
+ assert.strictEqual(requests.length, 1);
+ assert.strictEqual(dnsQueries.length, 0);
+ });
+ });
+
+ [
+ [{ address: '127.0.0.1', family: 4 }],
+ [{ address: '10.0.0.1', family: 4 }],
+ [{ address: '169.254.169.254', family: 4 }],
+ [{ address: '::1', family: 6 }],
+ [{ address: 'fd00::1', family: 6 }],
+ [{ address: '::ffff:192.168.1.1', family: 6 }],
+ [{ address: 'fe80::1%eth0', family: 6 }],
+ [{ address: '8.8.8.8', family: 4 }, { address: '10.0.0.1', family: 4 }],
+ [{ address: '8.8.8.8', family: 4 }, { address: '::1', family: 6 }],
+ [{ address: 'not-an-address', family: 4 }],
+ [{ address: '8.8.8.8', family: 6 }]
+ ].forEach(addresses => {
+ it('blocks unsafe or mixed DNS answers ' + JSON.stringify(addresses), () => {
+ dnsAddresses = addresses;
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(loggedErrors[0].message, 'Blocked webhook destination');
+ assert.strictEqual(timeoutCleared, true);
+ });
+ });
+
+ [[], null].forEach(addresses => {
+ it('skips empty DNS answers ' + JSON.stringify(addresses), () => {
+ dnsAddresses = addresses;
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(loggedErrors[0].message, 'Webhook DNS lookup failed');
+ });
+ });
+
+ it('logs DNS failures without leaking resolver error details', () => {
+ dnsError = new Error('secret URL credentials');
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(loggedErrors[0].message, 'Webhook DNS lookup failed');
+ });
+
+ it('times out DNS lookup and ignores late answers', () => {
+ deferredLookup = true;
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ timeoutCallback();
+ dnsCallback(null, dnsAddresses);
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(loggedErrors.length, 1);
+ assert.strictEqual(loggedErrors[0].message, 'Webhook DNS lookup timed out');
+ });
+
+ it('pins the checked address without a second DNS lookup', () => {
+ emit({});
+ dnsAddresses = [{ address: '127.0.0.1', family: 4 }];
+ const options = requests[0];
+ options.lookup('example.test', {}, (err, address, family) => {
+ assert.ifError(err);
+ assert.strictEqual(address, '8.8.8.8');
+ assert.strictEqual(family, 4);
+ });
+ options.lookup('example.test', { all: true }, (err, addresses) => {
+ assert.ifError(err);
+ assert.deepStrictEqual(addresses, [{ address: '8.8.8.8', family: 4 }]);
+ });
+ options.lookup('attacker.test', {}, err => assert.ok(err));
+ assert.deepStrictEqual(dnsQueries, ['example.test']);
+ assert.strictEqual(options.proxy, null);
+ assert.strictEqual(options.pool, false);
+ assert.strictEqual(options.followRedirect, false);
+ assert.strictEqual(options.followAllRedirects, false);
+ });
+
+ it('pins a public IPv6 DNS answer', () => {
+ dnsAddresses = [{ address: '2606:4700:4700::1111', family: 6 }];
+ emit({});
+ requests[0].lookup('example.test', (err, address, family) => {
+ assert.ifError(err);
+ assert.strictEqual(address, dnsAddresses[0].address);
+ assert.strictEqual(family, 6);
+ });
+ assert.strictEqual(requests[0].family, 6);
+ });
+
+ [
+ 'not a URL', 'http://[', 'https://%ZZ@example.com/hook',
+ 'https://', '/relative/hook', '//example.com/hook',
+ 'ftp://example.com/hook', 'file:///tmp/hook',
+ 'https://example.com:65536/hook', 'https://example.com:abc/hook',
+ 'https://example.com/white space', 'https://example.com/\nheader',
+ 'http://unix:/tmp/service.sock:/hook', 123, {}
+ ].forEach(url => {
+ it('skips invalid URL ' + JSON.stringify(url) + ' without throwing', () => {
+ destination = url;
+ assert.doesNotThrow(() => emit({}));
+ assert.strictEqual(requests.length, 0);
+ assert.strictEqual(loggedErrors.length, 1);
+ assert.strictEqual(loggedErrors[0].message, 'Invalid webhook URL; delivery skipped');
+ });
+ });
+
+ [
+ 'http://example.test/hook',
+ 'HTTP://example.test/hook',
+ 'http://evnotify.de/hook',
+ 'http://api.evnotify.com/hook'
+ ].forEach(url => {
+ it('rejects HTTP before DNS lookup or delivery for ' + url, () => {
+ destination = url;
+ assert.doesNotThrow(() => emit({ token: 'secret-token' }));
+ assert.deepStrictEqual(dnsQueries, []);
+ assert.deepStrictEqual(requests, []);
+ assert.strictEqual(loggedErrors.length, 1);
+ assert.strictEqual(loggedErrors[0].message, 'Invalid webhook URL; delivery skipped');
+ });
+ });
+
+ it('accepts a case-insensitive HTTPS scheme', () => {
+ destination = 'HTTPS://example.test/hook';
+ emit({});
+ assert.strictEqual(requests.length, 1);
+ assert.strictEqual(requests[0].uri, 'https://example.test/hook');
+ });
+
+ it('logs synchronous delivery errors without throwing', () => {
+ synchronousError = new Error('request construction failed');
+ assert.doesNotThrow(() => emit({}));
+ assert.strictEqual(loggedErrors[0].message, 'Webhook delivery failed');
+ });
+
+ it('posts to the stored URL and preserves event data without forwarding credentials', () => {
+ emit({ soh: 98 });
+ assert.strictEqual(requests.length, 1);
+ assert.strictEqual(requests[0].uri, destination);
+ assert.strictEqual(requests[0].method, 'POST');
+ assert.strictEqual(requests[0].json, true);
+ assert.strictEqual(requests[0].timeout, 10000);
+ assert.deepStrictEqual(requests[0].body, {
+ akey: 'ABC123', type: 'extended', response: { soh: 98 }
+ });
+ });
+
+ [
+ 'https://evnotify.de/hook',
+ 'https://evnotify.com/hook',
+ 'https://api.evnotify.de/hook',
+ 'https://api.evnotify.com/hook',
+ 'https://nested.api.evnotify.de/hook',
+ 'https://nested.api.evnotify.com/hook',
+ 'https://API.EVNOTIFY.DE/hook',
+ 'https://API.EVNOTIFY.COM/hook',
+ 'https://evnotify.de:443/hook',
+ 'https://attacker.com@evnotify.de/hook'
+ ].forEach(url => {
+ it('sends the token once and prevents redirects for ' + url, () => {
+ destination = url;
+ const response = Object.freeze({ akey: 'ABC123', token: 'secret-token', soh: 98 });
+ emit(response);
+ assert.strictEqual(requests[0].body.token, 'secret-token');
+ assert.deepStrictEqual(requests[0].body.response, { akey: 'ABC123', soh: 98 });
+ assert.strictEqual(requests[0].followRedirect, false);
+ assert.strictEqual(requests[0].followAllRedirects, false);
+ assert.strictEqual(response.token, 'secret-token');
+ });
+ });
+
+ [
+ 'https://fakeevnotify.de/hook',
+ 'https://fakeevnotify.com/hook',
+ 'https://evnotify.de.attacker.com/hook',
+ 'https://evnotify.com.attacker.de/hook',
+ 'https://evnotify.de.com/hook',
+ 'https://evnotify.com.de/hook',
+ 'https://evnotify.dev/hook',
+ 'https://evnotify.company/hook',
+ 'https://api.evnotify.de.attacker.com/hook',
+ 'https://evnotify.de@attacker.com/hook',
+ 'https://evnotify.com:password@attacker.com/hook',
+ 'https://example.test/evnotify.de?domain=evnotify.com',
+ 'https://evnotify.de./hook',
+ ].forEach(url => {
+ it('omits both token fields for ' + url, () => {
+ destination = url;
+ const response = Object.freeze({ token: 'secret-token', soh: 98 });
+ emit(response);
+ assert.ok(!Object.prototype.hasOwnProperty.call(requests[0].body, 'token'));
+ assert.deepStrictEqual(requests[0].body.response, { soh: 98 });
+ assert.strictEqual(response.token, 'secret-token');
+ });
+ });
+
+ [null, ''].forEach(url => {
+ it('does not send when the configured URL is ' + JSON.stringify(url), () => {
+ destination = url;
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ });
+ });
+
+ [[], null].forEach(result => {
+ it('does not send when settings lookup returns ' + JSON.stringify(result), () => {
+ rows = result;
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ });
+ });
+
+ it('does not send on database lookup failure', () => {
+ dbError = new Error('lookup failed');
+ emit({});
+ assert.strictEqual(requests.length, 0);
+ });
+
+ it('preserves array response payloads', () => {
+ emit([1, 2]);
+ assert.deepStrictEqual(requests[0].body.response, [1, 2]);
+ });
+
+ [200, 204].forEach(statusCode => {
+ it('does not log successful HTTP ' + statusCode + ' responses', () => {
+ transportResponse = { statusCode };
+ emit({});
+ assert.strictEqual(loggedErrors.length, 0);
+ });
+ });
+
+ [302, 400, 404, 500].forEach(statusCode => {
+ it('logs HTTP ' + statusCode + ' failures without sensitive data', () => {
+ destination = 'https://user:password@example.test/private?secret=value';
+ transportResponse = { statusCode, body: 'private response body' };
+ assert.doesNotThrow(() => emit({ private: 'private payload' }));
+ assert.strictEqual(loggedErrors.length, 1);
+ assert.strictEqual(loggedErrors[0].message, 'Webhook delivery failed: HTTP ' + statusCode);
+ const log = loggedErrors[0].stack;
+ ['user:password', 'example.test', '/private', 'secret=value', 'secret-token',
+ 'private response body', 'private payload'].forEach(value => {
+ assert.ok(!log.includes(value));
+ });
+ });
+ });
+
+ it('logs transport errors once without throwing or exposing error details', () => {
+ transportError = new Error('connection timed out for https://example.test/?token=secret-token');
+ transportResponse = { statusCode: 500 };
+ assert.doesNotThrow(() => emit({}));
+ assert.strictEqual(loggedErrors.length, 1);
+ assert.strictEqual(loggedErrors[0].message, 'Webhook delivery failed');
+ assert.ok(!loggedErrors[0].stack.includes('secret-token'));
+ });
+});
+
+describe('webhook HTTPS transport protection', () => {
+ let loader;
+ let server;
+ let originalHttpsConnect;
+
+ afterEach(done => {
+ if (loader) loader.restore();
+ if (originalHttpsConnect) https.Agent.prototype.createConnection = originalHttpsConnect;
+ if (server) server.close(done);
+ else done();
+ loader = null;
+ server = null;
+ originalHttpsConnect = null;
+ });
+
+ it('uses the pinned lookup with real HTTPS, preserves Host, and does not follow redirects', done => {
+ let received = 0;
+ let lookups = 0;
+ let pinnedLookups = 0;
+ const cert = fs.readFileSync(path.join(__dirname, 'fixtures/server-cert.pem'));
+ const key = fs.readFileSync(path.join(__dirname, 'fixtures/server-key.pem'));
+ server = https.createServer({ cert, key }, (req, res) => {
+ received++;
+ req.resume();
+ res.writeHead(302, { Location: 'http://127.0.0.1:' + server.address().port + '/internal' });
+ res.end();
+ });
+ server.listen(0, '127.0.0.1', () => {
+ const port = server.address().port;
+ server.on('request', req => assert.strictEqual(req.headers.host, 'example.test:' + port));
+ loader = loadModule('./modules/webhook', {
+ './modules/db': { query: (sql, params, cb) => cb(null, [{ webhook: 'https://example.test:' + port + '/hook' }]) },
+ dns: { lookup: (hostname, options, cb) => {
+ lookups++;
+ cb(null, [{ address: '8.8.8.8', family: 4 }]);
+ } },
+ request: (options, callback) => {
+ // Route the approved public address to a local fixture only in this test.
+ // The real request library must pass our lookup through to Node's socket.
+ options.ca = cert; // Trust only the fixture certificate; keep TLS verification enabled.
+ const lookup = options.lookup;
+ options.lookup = (hostname, lookupOptions, cb) => {
+ pinnedLookups++;
+ lookup(hostname, lookupOptions, (err, address, family) => {
+ assert.ifError(err);
+ if (lookupOptions.all) {
+ assert.deepStrictEqual(address, [{ address: '8.8.8.8', family: 4 }]);
+ cb(null, [{ address: '127.0.0.1', family: 4 }]);
+ } else {
+ assert.strictEqual(address, '8.8.8.8');
+ cb(null, '127.0.0.1', family);
+ }
+ });
+ };
+ return realRequest(options, (err, response) => {
+ callback(err);
+ try {
+ assert.ifError(err);
+ assert.strictEqual(response.statusCode, 302);
+ assert.strictEqual(received, 1);
+ assert.strictEqual(lookups, 1);
+ assert.strictEqual(pinnedLookups, 1);
+ done();
+ } catch (failure) { done(failure); }
+ });
+ }
+ });
+ loader.module.emit('ABC123', 'secret-token', 'soc', { display: 80 });
+ });
+ });
+
+ it('preserves TLS servername and certificate verification with a pinned address', done => {
+ originalHttpsConnect = https.Agent.prototype.createConnection;
+ const expectedError = new Error('test stops connection before network access');
+ let connectionOptions;
+ https.Agent.prototype.createConnection = (options, callback) => {
+ connectionOptions = options;
+ process.nextTick(() => callback(expectedError));
+ };
+ loader = loadModule('./modules/webhook', {
+ './modules/db': { query: (sql, params, cb) => cb(null, [{ webhook: 'https://example.test/hook' }]) },
+ dns: { lookup: (hostname, options, cb) => cb(null, [{ address: '8.8.8.8', family: 4 }]) },
+ request: options => realRequest(options, err => {
+ // Expected connection failure belongs to the fixture, not webhook delivery.
+ try {
+ assert.strictEqual(err, expectedError);
+ assert.strictEqual(connectionOptions.servername, 'example.test');
+ assert.notStrictEqual(connectionOptions.rejectUnauthorized, false);
+ connectionOptions.lookup('example.test', {}, (lookupError, address) => {
+ assert.ifError(lookupError);
+ assert.strictEqual(address, '8.8.8.8');
+ });
+ done();
+ } catch (failure) { done(failure); }
+ })
+ });
+ loader.module.emit('ABC123', 'secret-token', 'soc', { display: 80 });
+ });
+});