aaronsb_obsidian-mcp-plugin/tests/network-classifier.test.ts
Aaron Bockelie 2b4ef6a9b4 fix(network): address PR #211 self-review findings (ADR-107)
Blocking — userSuppliedCert discriminator:
- Was: certificateConfig.selfSigned === false (never set false in UI;
  rows 5/7/9 of the ADR-107 table unreachable, defaulting to over-warn).
- Now: !!(certPath && keyPath) — actual cert provenance signal.
  Fixed in src/mcp-server.ts and src/main.ts settings UI badge.

Non-blocking #1 — migration Notice race:
- Was: isExistingPreBindModeInstall() re-read data.json after
  loadSettings() may have already persisted bindMode='loopback' as
  part of the apiKey auto-save.
- Now: snapshot raw loadData() before loadSettings(), test the snapshot.

Non-blocking #2 — loopback host matcher tightened:
- Was: startsWith('127.') accepted '127.evil.com'.
- Now: strict dotted-quad regex /^127(\.\d{1,3}){3}$/ with octet range.
  New tests cover hostile inputs (127.evil.com, 127.0.0.1.attacker.tld,
  127.300.0.1, 1270.0.0.1).

Non-blocking #3 — Reconfigure hint:
- ADR-107 specifies the 🔴 badge includes a Reconfigure hint;
  implementation now renders 'Reconfigure: switch the bind address
  below to Loopback, or enable HTTPS.' beneath the verdict reason.

Non-blocking #4 — empty custom host UX:
- When bindMode='custom' && customBindHost is empty, render an
  explicit Notice that the server will fall back to loopback until a
  host is entered (resolves the silent 🟢 OK in that state).

Lint clean, 296/296 tests pass.
2026-05-24 17:06:16 -05:00

195 lines
7 KiB
TypeScript

import {
classify,
classifyFromSettings,
resolveListenHost,
resolveBindAxis,
normalizeBindInput,
agentInstructionsForVerdict,
NetworkState
} from '../src/utils/network-classifier';
describe('classify() — 9-cell ADR-107 table', () => {
const cases: Array<{ name: string; state: NetworkState; expected: 'ok' | 'warn' | 'jail' }> = [
{ name: '1: http + loopback', state: { protocol: 'http', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
{ name: '2: http + custom-other', state: { protocol: 'http', bind: 'custom-other', certSource: 'self' }, expected: 'jail' },
{ name: '3: http + all', state: { protocol: 'http', bind: 'all', certSource: 'self' }, expected: 'jail' },
{ name: '4: https + loopback + self', state: { protocol: 'https', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
{ name: '5: https + loopback + user', state: { protocol: 'https', bind: 'loopback', certSource: 'user' }, expected: 'ok' },
{ name: '6: https + custom-other + self',state: { protocol: 'https', bind: 'custom-other', certSource: 'self' }, expected: 'warn' },
{ name: '7: https + custom-other + user',state: { protocol: 'https', bind: 'custom-other', certSource: 'user' }, expected: 'ok' },
{ name: '8: https + all + self', state: { protocol: 'https', bind: 'all', certSource: 'self' }, expected: 'warn' },
{ name: '9: https + all + user', state: { protocol: 'https', bind: 'all', certSource: 'user' }, expected: 'ok' }
];
for (const c of cases) {
test(c.name, () => {
const v = classify(c.state);
expect(v.class).toBe(c.expected);
expect(v.reason).toBeTruthy();
});
}
test('custom-loopback under http classifies as ok (not jail)', () => {
expect(classify({ protocol: 'http', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
});
test('custom-loopback under https + self classifies as ok (not warn)', () => {
expect(classify({ protocol: 'https', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
});
});
describe('normalizeBindInput()', () => {
test.each([
['127.0.0.1', 'loopback'],
['localhost', 'loopback'],
['LOCALHOST', 'loopback'],
['::1', 'loopback'],
['::ffff:127.0.0.1', 'loopback'],
['127.0.0.99', 'loopback'],
['127.255.255.254', 'loopback'],
[' 127.0.0.1 ', 'loopback']
])('"%s" collapses to loopback mode', (input, expectedMode) => {
const out = normalizeBindInput('custom', input);
expect(out.mode).toBe(expectedMode);
expect(out.customHost).toBe('');
});
test.each([
'127.evil.com',
'127.0.0.1.attacker.tld',
'127.300.0.1',
'1270.0.0.1'
])('"%s" does NOT collapse to loopback (hostile/invalid)', (input) => {
const out = normalizeBindInput('custom', input);
expect(out.mode).toBe('custom');
expect(out.customHost).toBe(input);
});
test.each([
['0.0.0.0', 'all'],
['::', 'all'],
[' 0.0.0.0', 'all']
])('"%s" collapses to all mode', (input, expectedMode) => {
const out = normalizeBindInput('custom', input);
expect(out.mode).toBe(expectedMode);
expect(out.customHost).toBe('');
});
test('arbitrary LAN address stays custom and is trimmed', () => {
const out = normalizeBindInput('custom', ' 192.168.1.50 ');
expect(out.mode).toBe('custom');
expect(out.customHost).toBe('192.168.1.50');
});
test('empty custom stays custom with empty host', () => {
expect(normalizeBindInput('custom', '')).toEqual({ mode: 'custom', customHost: '' });
expect(normalizeBindInput('custom', ' ')).toEqual({ mode: 'custom', customHost: '' });
});
test('non-custom modes blank the customHost', () => {
expect(normalizeBindInput('loopback', '192.168.1.5')).toEqual({ mode: 'loopback', customHost: '' });
expect(normalizeBindInput('all', '192.168.1.5')).toEqual({ mode: 'all', customHost: '' });
});
});
describe('resolveListenHost()', () => {
test('loopback → 127.0.0.1', () => {
expect(resolveListenHost('loopback', '')).toBe('127.0.0.1');
});
test('all → 0.0.0.0', () => {
expect(resolveListenHost('all', '')).toBe('0.0.0.0');
});
test('custom → trimmed customHost', () => {
expect(resolveListenHost('custom', ' 192.168.1.50 ')).toBe('192.168.1.50');
});
test('custom with empty host falls back to loopback (defensive)', () => {
expect(resolveListenHost('custom', '')).toBe('127.0.0.1');
expect(resolveListenHost('custom', ' ')).toBe('127.0.0.1');
});
});
describe('resolveBindAxis()', () => {
test('loopback mode → loopback axis', () => {
expect(resolveBindAxis('loopback', '')).toBe('loopback');
});
test('all mode → all axis', () => {
expect(resolveBindAxis('all', '')).toBe('all');
});
test('custom with empty host → custom-loopback (defensive)', () => {
expect(resolveBindAxis('custom', '')).toBe('custom-loopback');
});
test('custom with loopback alias → custom-loopback', () => {
expect(resolveBindAxis('custom', '127.0.0.1')).toBe('custom-loopback');
expect(resolveBindAxis('custom', 'localhost')).toBe('custom-loopback');
});
test('custom with LAN address → custom-other', () => {
expect(resolveBindAxis('custom', '192.168.1.50')).toBe('custom-other');
});
});
describe('classifyFromSettings()', () => {
test('default install (http + loopback) → ok', () => {
const v = classifyFromSettings({
httpsEnabled: false,
bindMode: 'loopback',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('ok');
});
test('http + all → jail', () => {
const v = classifyFromSettings({
httpsEnabled: false,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('jail');
});
test('https + all + self-signed → warn', () => {
const v = classifyFromSettings({
httpsEnabled: true,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('warn');
});
test('https + all + user cert → ok', () => {
const v = classifyFromSettings({
httpsEnabled: true,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: true
});
expect(v.class).toBe('ok');
});
});
describe('agentInstructionsForVerdict()', () => {
test('returns null for ok', () => {
expect(agentInstructionsForVerdict({ class: 'ok', reason: 'x' }, '127.0.0.1', 3001)).toBeNull();
});
test('returns null for warn', () => {
expect(agentInstructionsForVerdict({ class: 'warn', reason: 'x' }, '0.0.0.0', 3443)).toBeNull();
});
test('returns warning string for jail, includes host and port', () => {
const s = agentInstructionsForVerdict({ class: 'jail', reason: 'x' }, '0.0.0.0', 3001);
expect(s).not.toBeNull();
expect(s).toContain('SECURITY WARNING');
expect(s).toContain('0.0.0.0:3001');
expect(s).toContain('cleartext');
});
});