DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
- Extract LAN/Tailscale classification into src/utilities/network-detector.js
(detectInterfaceIps, isTailscaleIP, isPrivateLanIP). The route handler in
src/app.js is now a thin adapter — no inline 'os' reference, no inline
classification logic.
- Drop the dead 'collectNetworkInterfaces' / inline 'detectInterfaceIps'
helpers from app.js (the original ReferenceError shape).
- Add __tests__/network-ips-route.test.js (16 tests):
- Detector unit tests for Tailscale CGNAT (100.64/10) and RFC 1918 LAN
ranges with malformed-input guards.
- detectInterfaceIps() behavior under os-mocked interfaces with IPv4
filtering, IPv6 exclusion, null addrs tolerance.
- Route handler integration tests asserting 200 + canonical envelope on
the populated path, the empty-path (regression case for the original
bug shape), and HOST_LAN_IP/HOST_TAILSCALE_IP env override branches.
- Source-of-truth test that fails if a future refactor reintroduces an
inline detectInterfaceIps() in src/app.js or references 'os' without
a prior require('os') line.
- Fix latent ESLint Error in backup-manager.js: the 'default:' case had a
'const minutes' declaration without a surrounding block, triggering
no-case-declarations. Added the block braces.
Pre-fix baseline: no test exercised this route, so the 1071-test suite
passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new
ESLint warnings (10 pre-existing warnings in backup-manager.js unrelated
to this commit).
This commit is contained in:
@@ -185,24 +185,6 @@ async function createApp() {
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
function isPrivateLan(ip) {
|
||||
if (!ip) return false;
|
||||
if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true;
|
||||
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip);
|
||||
}
|
||||
|
||||
function collectNetworkInterfaces(osModule) {
|
||||
const out = [];
|
||||
const interfaces = osModule.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
out.push({ name, ip: addr.address });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
|
||||
async function getTailscaleStatus() {
|
||||
// Stub for now - will be populated by context
|
||||
@@ -865,29 +847,10 @@ async function createApp() {
|
||||
res.status(statusCode).send();
|
||||
}, 'probe'));
|
||||
|
||||
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
||||
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
||||
function detectInterfaceIps() {
|
||||
const os = require('os');
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
const all = [];
|
||||
let lan = null;
|
||||
let tailscale = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const { address: ip } = addr;
|
||||
all.push({ name, ip });
|
||||
if (!tailscale && ip.startsWith('100.')) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
// Network IPs endpoint — see src/utilities/network-detector.js for the
|
||||
// classification logic. The detector module is what the regression test
|
||||
// loads; this handler is a thin adapter (DC-031).
|
||||
const { detectInterfaceIps } = require('./utilities/network-detector');
|
||||
|
||||
// Network IPs endpoint
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
@@ -903,13 +866,10 @@ async function createApp() {
|
||||
};
|
||||
|
||||
if (!envLan || !envTailscale) {
|
||||
result.all = collectNetworkInterfaces(os);
|
||||
if (!result.tailscale) {
|
||||
result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null;
|
||||
}
|
||||
if (!result.lan) {
|
||||
result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null;
|
||||
}
|
||||
const detected = detectInterfaceIps();
|
||||
result.all = detected.all;
|
||||
if (!result.lan) result.lan = detected.lan;
|
||||
if (!result.tailscale) result.tailscale = detected.tailscale;
|
||||
}
|
||||
|
||||
ok(res, result);
|
||||
|
||||
@@ -84,7 +84,7 @@ class BackupManager extends EventEmitter {
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default:
|
||||
default: {
|
||||
// Custom interval in minutes
|
||||
const minutes = parseInt(backup.schedule, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
@@ -93,6 +93,7 @@ class BackupManager extends EventEmitter {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Network interface detection — extracts the LAN + Tailscale IP discovery logic
|
||||
* out of src/app.js so it can be unit-tested in isolation (DC-031 regression guard).
|
||||
*
|
||||
* Why this lives in its own module instead of inside createApp():
|
||||
* The route handler at /api/v1/network/ips previously had this logic inlined.
|
||||
* A bad refactor dropped the `require('os')` line and left a `collectNetworkInterfaces(os)`
|
||||
* reference that crashed with ReferenceError on every request — and no test caught it
|
||||
* because no test exercised the route handler. By extracting the detector here,
|
||||
* (a) the module can be unit-tested without booting the entire Express app and
|
||||
* its middleware/auth/CSRF stack, and
|
||||
* (b) the route handler in src/app.js becomes a thin adapter that calls
|
||||
* `detectInterfaceIps()` — if a future refactor reintroduces an inline `os`
|
||||
* reference, the inline-block of test will still pass, but the regression
|
||||
* suite around this module will catch any divergence in the detector contract.
|
||||
*
|
||||
* Public API:
|
||||
* detectInterfaceIps() -> { lan: string|null, tailscale: string|null, all: Array<{name, ip}> }
|
||||
* Scans the host's network interfaces and returns the first matching LAN and
|
||||
* Tailscale IPv4 address (if any), plus the full list of non-internal IPv4
|
||||
* interfaces. Used by /api/v1/network/ips to pre-fill the "Add Service" form
|
||||
* auto-detect UX.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Host LAN IPv4 ranges per RFC 1918:
|
||||
// 10.0.0.0/8
|
||||
// 172.16.0.0/12 -> 172.16.* through 172.31.*
|
||||
// 192.168.0.0/16
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
|
||||
/**
|
||||
* Scan the host's network interfaces and return the first LAN and Tailscale IPv4
|
||||
* addresses (if found), plus the full list of non-internal IPv4 interfaces.
|
||||
*
|
||||
* Tailscale IP range: 100.64.0.0/10 (Tailscale assigns addresses from this CGNAT
|
||||
* range — the entire 100.64.0.0–100.127.255.255 block).
|
||||
*
|
||||
* @returns {{lan: string|null, tailscale: string|null, all: Array<{name: string, ip: string}>}}
|
||||
*/
|
||||
function detectInterfaceIps() {
|
||||
// Use a lazy require so test code that mocks `os` can swap it before this
|
||||
// function executes. Production callers hit the real `os` module the first
|
||||
// time the route handler runs.
|
||||
const os = require('os');
|
||||
const all = [];
|
||||
let lan = null;
|
||||
let tailscale = null;
|
||||
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs || []) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
const { address: ip } = addr;
|
||||
all.push({ name, ip });
|
||||
if (!tailscale && isTailscaleIP(ip)) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailscale assigns IPv4 addresses from 100.64.0.0/10 (the CGNAT space carved out
|
||||
* for it). The first octet must be 100, the second octet must be in [64, 127].
|
||||
*
|
||||
* @param {string} ip an IPv4 dotted-quad address (e.g. "100.100.50.25")
|
||||
* @returns {boolean} true if `ip` falls in the Tailscale CGNAT range
|
||||
*/
|
||||
function isTailscaleIP(ip) {
|
||||
if (!ip) return false;
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const first = parseInt(parts[0], 10);
|
||||
const second = parseInt(parts[1], 10);
|
||||
if (Number.isNaN(first) || Number.isNaN(second)) return false;
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an IPv4 address is in one of the RFC 1918 private LAN ranges.
|
||||
* @param {string} ip an IPv4 dotted-quad address
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPrivateLanIP(ip) {
|
||||
if (!ip) return false;
|
||||
return LAN_RANGE.test(ip);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectInterfaceIps,
|
||||
isTailscaleIP,
|
||||
isPrivateLanIP,
|
||||
LAN_RANGE,
|
||||
};
|
||||
Reference in New Issue
Block a user