DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- 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:
Krystie
2026-07-06 15:06:28 -07:00
committed by Hermes
parent 71fd7cd58f
commit 369827c43f
5 changed files with 471 additions and 50 deletions
+8 -48
View File
@@ -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.0100.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,
};