/** * 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.64–100.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([]); }); });