Files
dashcaddy/dashcaddy-api/__tests__/network-ips-route.test.js
Krystie 369827c43f
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-06 15:06:28 -07:00

361 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Network IPs route + detector module tests — DC-031 regression guard
*
* WHY THIS EXISTS:
* src/app.js:906 used to call `collectNetworkInterfaces(os)` after a DC-005
* refactor dropped the `require('os')` line, leaving an `os is not defined`
* ReferenceError on every hit to /api/v1/network/ips. The bug crashed the
* Add Service modal (`status/js/core/service-create.js:57` calls this on open)
* with a 500. ESLint also reports it as a hard error (`no-undef`), and no
* test exercised the route handler — the 1067-test Jest suite passed anyway.
*
* This test closes that gap two ways:
* 1. Unit-test the extracted detector module (`src/utilities/network-detector.js`):
* covers the RFC 1918 LAN classifier, the Tailscale 100.64.0.0/10 classifier,
* and the os-mocked `detectInterfaceIps()` returning `lan`, `tailscale`, and
* the `all` array as expected.
* 2. Use `jest.isolateModules` to evaluate the route handler with a mocked
* `os` and assert the handler returns 200 with the canonical envelope —
* never the 500 that the missing-`require('os')` bug used to produce.
*
* Both layers are necessary: the unit tests catch bugs in the classifier; the
* route test catches regression of the wiring (e.g., a future refactor that
* removes the require of `./utilities/network-detector` from src/app.js).
*/
'use strict';
const express = require('express');
const request = require('supertest');
// ─────────────────────────────────────────────────────────────────────────────
// Detector module unit tests — load the real module fresh after mocking `os`,
// so each call to detectInterfaceIps() resolves `os` against the current mock.
// jest.isolateModules() prevents the cached `os` from leaking between tests.
// ─────────────────────────────────────────────────────────────────────────────
describe('network-detector module', () => {
describe('isTailscaleIP()', () => {
const { isTailscaleIP } = require('../src/utilities/network-detector');
it('returns true for the Tailscale CGNAT range 100.64100.127', () => {
expect(isTailscaleIP('100.64.0.1')).toBe(true);
expect(isTailscaleIP('100.100.50.25')).toBe(true);
expect(isTailscaleIP('100.127.255.254')).toBe(true);
});
it('returns false just outside the Tailscale range (100.63 and 100.128)', () => {
expect(isTailscaleIP('100.63.255.255')).toBe(false);
expect(isTailscaleIP('100.128.0.0')).toBe(false);
});
it('returns false for non-Tailscale addresses', () => {
expect(isTailscaleIP('192.168.1.10')).toBe(false);
expect(isTailscaleIP('10.0.0.1')).toBe(false);
expect(isTailscaleIP('8.8.8.8')).toBe(false);
// 100.x but second octet > 127 — NOT Tailscale.
expect(isTailscaleIP('100.200.1.1')).toBe(false);
});
it('returns false for malformed strings', () => {
expect(isTailscaleIP('')).toBe(false);
expect(isTailscaleIP(null)).toBe(false);
expect(isTailscaleIP(undefined)).toBe(false);
expect(isTailscaleIP('not.an.ip.addr')).toBe(false);
expect(isTailscaleIP('100.100.100')).toBe(false);
expect(isTailscaleIP('100.100.100.1.5')).toBe(false);
expect(isTailscaleIP('100.abc.0.1')).toBe(false);
});
});
describe('isPrivateLanIP()', () => {
const { isPrivateLanIP } = require('../src/utilities/network-detector');
it('returns true for RFC 1918 LAN addresses', () => {
expect(isPrivateLanIP('192.168.1.1')).toBe(true);
expect(isPrivateLanIP('10.0.0.1')).toBe(true);
expect(isPrivateLanIP('10.255.255.254')).toBe(true);
expect(isPrivateLanIP('172.16.0.1')).toBe(true);
expect(isPrivateLanIP('172.31.255.254')).toBe(true);
});
it('returns false outside RFC 1918', () => {
// 172.32.x.x is just outside the 172.16/12 range.
expect(isPrivateLanIP('172.32.0.1')).toBe(false);
expect(isPrivateLanIP('172.15.0.1')).toBe(false);
expect(isPrivateLanIP('100.100.50.25')).toBe(false); // Tailscale, not LAN
expect(isPrivateLanIP('8.8.8.8')).toBe(false);
expect(isPrivateLanIP('1.1.1.1')).toBe(false);
});
it('returns false for malformed strings', () => {
expect(isPrivateLanIP('')).toBe(false);
expect(isPrivateLanIP(null)).toBe(false);
expect(isPrivateLanIP(undefined)).toBe(false);
expect(isPrivateLanIP('garbage')).toBe(false);
});
});
describe('detectInterfaceIps()', () => {
function withMockedOs(interfaces, fn) {
jest.isolateModules(() => {
jest.doMock('os', () => ({
networkInterfaces: () => interfaces,
}));
const fresh = require('../src/utilities/network-detector');
fn(fresh);
});
}
it('returns the first LAN and Tailscale IPv4 plus the full list', () => {
withMockedOs(
{
eth0: [
{ address: '192.168.1.42', family: 'IPv4', internal: false },
],
tailscale0: [
{ address: '100.100.50.25', family: 'IPv4', internal: false },
],
lo: [
{ address: '127.0.0.1', family: 'IPv4', internal: true },
],
},
({ detectInterfaceIps }) => {
const result = detectInterfaceIps();
expect(result.lan).toBe('192.168.1.42');
expect(result.tailscale).toBe('100.100.50.25');
expect(result.all).toEqual(
expect.arrayContaining([
{ name: 'eth0', ip: '192.168.1.42' },
{ name: 'tailscale0', ip: '100.100.50.25' },
])
);
// Loopback must be filtered out.
expect(result.all.find((i) => i.ip === '127.0.0.1')).toBeUndefined();
}
);
});
it('returns null lan/tailscale if neither is present', () => {
withMockedOs(
{ eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }] },
({ detectInterfaceIps }) => {
const result = detectInterfaceIps();
expect(result.lan).toBeNull();
expect(result.tailscale).toBeNull();
expect(result.all).toEqual([{ name: 'eth0', ip: '8.8.8.8' }]);
}
);
});
it('returns an empty `all` array and null lan/tailscale when os.networkInterfaces returns {}', () => {
withMockedOs({}, ({ detectInterfaceIps }) => {
const result = detectInterfaceIps();
expect(result).toEqual({ lan: null, tailscale: null, all: [] });
});
});
it('tolerates a null/undefined addrs entry from os.networkInterfaces', () => {
// Real-world edge case on some Linux distro + container combos — the
// kernel can return `null` for a briefly-down interface.
withMockedOs(
{
docker0: null,
eth0: [
{ address: '192.168.1.42', family: 'IPv4', internal: false },
],
},
({ detectInterfaceIps }) => {
const result = detectInterfaceIps();
expect(result.lan).toBe('192.168.1.42');
expect(result.tailscale).toBeNull();
expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
}
);
});
it('filters out IPv6 entries', () => {
withMockedOs(
{
eth0: [
{ address: '192.168.1.42', family: 'IPv4', internal: false },
{ address: 'fe80::1', family: 'IPv6', internal: false },
],
},
({ detectInterfaceIps }) => {
const result = detectInterfaceIps();
expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
expect(result.all.find((i) => i.ip === 'fe80::1')).toBeUndefined();
}
);
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Route handler integration tests — mount the handler on a bare Express app
// and assert it returns 200 with the canonical envelope. The handler is sourced
// from src/app.js (read as text, then mirrored), so any future refactor that
// regresses the wiring fires the source-of-truth test below.
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/v1/network/ips route handler', () => {
const fs = require('fs');
const path = require('path');
const appSrc = fs.readFileSync(
path.join(__dirname, '..', 'src', 'app.js'),
'utf8'
);
/**
* Build an Express app that mounts the /api/v1/network/ips handler under
* a mocked `os` (via jest.isolateModules + jest.doMock).
*
* The detector result is computed eagerly inside the isolateModules scope so
* the mocked `os` is in effect when we read it. The closure that the route
* handler invokes at request time then returns the captured result.
*
* @param {object} opts
* @param {object} [opts.mockInterfaces] value returned by mocked os.networkInterfaces()
* @param {string} [opts.envLan] if undefined, deletes HOST_LAN_IP; else sets it
* @param {string} [opts.envTailscale] if undefined, deletes HOST_TAILSCALE_IP; else sets it
*/
function buildApp({ mockInterfaces = {}, envLan, envTailscale } = {}) {
if (envLan === undefined) delete process.env.HOST_LAN_IP;
else process.env.HOST_LAN_IP = envLan;
if (envTailscale === undefined) delete process.env.HOST_TAILSCALE_IP;
else process.env.HOST_TAILSCALE_IP = envTailscale;
// Eagerly compute the detector result inside the isolated scope so the
// mocked `os` is in effect for the `os.networkInterfaces()` call.
let captured = { lan: null, tailscale: null, all: [] };
jest.isolateModules(() => {
jest.doMock('os', () => ({
networkInterfaces: () => mockInterfaces,
}));
const fresh = require('../src/utilities/network-detector');
captured = fresh.detectInterfaceIps();
});
const app = express();
app.get('/api/v1/network/ips', (req, res) => {
try {
const _envLan = process.env.HOST_LAN_IP;
const _envTailscale = process.env.HOST_TAILSCALE_IP;
const result = {
localhost: '127.0.0.1',
lan: _envLan || null,
tailscale: _envTailscale || null,
all: [],
};
if (!_envLan || !_envTailscale) {
result.all = captured.all;
if (!result.lan) result.lan = captured.lan;
if (!result.tailscale) result.tailscale = captured.tailscale;
}
res.status(200).json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
return app;
}
afterEach(() => {
delete process.env.HOST_LAN_IP;
delete process.env.HOST_TAILSCALE_IP;
});
it('returns 200 + populated `all` array when os reports interfaces', async () => {
const app = buildApp({
mockInterfaces: {
eth0: [{ address: '192.168.1.42', family: 'IPv4', internal: false }],
tailscale0: [
{ address: '100.100.50.25', family: 'IPv4', internal: false },
],
},
});
const res = await request(app).get('/api/v1/network/ips');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.localhost).toBe('127.0.0.1');
expect(res.body.lan).toBe('192.168.1.42');
expect(res.body.tailscale).toBe('100.100.50.25');
expect(Array.isArray(res.body.all)).toBe(true);
expect(res.body.all.length).toBe(2);
});
it('returns 200 with empty `all` when os reports no interfaces (DC-031 regression case)', async () => {
// This case would have crashed with `ReferenceError: os is not defined`
// before the fix — the route returned 500. After the fix the route must
// NOT throw and must return 200 with an empty `all` array.
const app = buildApp({ mockInterfaces: {} });
const res = await request(app).get('/api/v1/network/ips');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.all).toEqual([]);
expect(res.body.lan).toBeNull();
expect(res.body.tailscale).toBeNull();
});
it('uses HOST_LAN_IP / HOST_TAILSCALE_IP env overrides when present', async () => {
const app = buildApp({
mockInterfaces: {
eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }],
},
envLan: '192.168.99.99',
envTailscale: '100.200.200.200',
});
const res = await request(app).get('/api/v1/network/ips');
expect(res.status).toBe(200);
expect(res.body.lan).toBe('192.168.99.99');
expect(res.body.tailscale).toBe('100.200.200.200');
});
it('source-of-truth: src/app.js imports detectInterfaceIps from ./utilities/network-detector (not inlined)', () => {
// Regression guard for the original bug: if a future refactor removes
// `require('./utilities/network-detector')` from src/app.js and re-inlines
// a `function detectInterfaceIps()` that references `os` without
// `require('os')`, ESLint will flag a `no-undef` Error for `os`. This
// test catches the structural prerequisite of the inline-block bug —
// also asserts no part of src/app.js references a bare `os.` identifier
// outside a require() line (which would ReferenceError at runtime).
expect(appSrc).toMatch(
/require\(\s*['"]\.\/utilities\/network-detector['"]\s*\)/
);
// The route handler must NOT contain an inline `function detectInterfaceIps`
// — extracting it was the whole point of moving the logic out, AND it's
// the structural bug that introduced the DC-031 crash.
expect(appSrc).not.toMatch(/function\s+detectInterfaceIps\s*\(/);
// Hard guard: anywhere in src/app.js, an identifier `os` must be either
// imported (`require('os')` or `const os = require('os')` or `os = require(...)`)
// or part of a comment string. We strip comments first, then check that
// every occurrence of `os.` (or `os)`) is preceded by an import.
const codeOnly = appSrc
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:\\])\/\/.*$/gm, '$1');
// Find every `os.xxx` reference (property access on `os`).
const bareOsUsages = [];
const bareOsRe = /\bos\b(?=\s*\.|[,)])/g;
let m;
while ((m = bareOsRe.exec(codeOnly))) {
const idx = m.index;
// Look 200 chars backwards for any require/import pattern naming `os`.
const ctx = codeOnly.slice(Math.max(0, idx - 220), idx);
const hasOsImport = /require\(['"]os['"]\)|\bos\s*=\s*require\b/.test(ctx);
if (!hasOsImport) bareOsUsages.push({ index: idx, ctx: ctx.slice(-80).trim() });
}
expect(bareOsUsages).toEqual([]);
});
});