import assert from 'node:assert/strict'; import test from 'node:test'; import { classifyDomain, createDomainTrafficService, } from '../../dist/server/services/domainTrafficService.js'; import { deviceId } from '../../dist/server/services/deviceInventoryService.js'; const mac = '00:11:22:33:44:55'; const id = deviceId(mac); const device = { ip: '192.168.50.7', mac, alias: 'MacBook' }; const connection = (connectionId, type, host, upload, download, sourceIP = device.ip, chains = ['vpn-out']) => ({ id: connectionId, metadata: { type, host, sourceIP }, upload, download, chains, }); const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({ id: connectionId, startedAt: '2026-08-31T10:00:00.000Z', closedAt: null, inbound: { tag: 'tproxy-in', type: 'tproxy' }, network: 'tcp', protocol: 'tls', source: { ip: device.ip, port: 54_000 }, destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' }, origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' }, route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'channel-selector', outboundType: 'selector', chain: ['channel-primary', 'channel-selector'], rule: 'final', }, traffic: { uploadBytes, downloadBytes, uploadBytesPerSecond: '0', downloadBytesPerSecond: '0', }, ...overrides, }); const nativeBatch = (connections, overrides = {}) => ({ epoch: 'sing-box-100', observedAt: '2026-08-31T10:00:00.000Z', reset: false, connections, closedIds: [], ...overrides, }); test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => { let response = { connections: [ connection('direct', 'tproxy/tproxy-in', 'one.example', 10, 100, device.ip, ['direct']), connection('vpn', 'tproxy/tproxy-in', 'two.example', 20, 200), connection('unknown', 'tproxy/tproxy-in', 'three.example', 30, 300, device.ip, []), connection('unmapped', 'tproxy/tproxy-in', 'four.example', 40, 400, '192.168.50.99'), ] }; const service = createDomainTrafficService({ observe: async () => response, devices: () => [device], }); await service.refresh(); response = { connections: [ connection('direct', 'tproxy/tproxy-in', 'one.example', 15, 110, device.ip, ['direct']), connection('vpn', 'tproxy/tproxy-in', 'two.example', 25, 220, device.ip, ['direct']), connection('unknown', 'tproxy/tproxy-in', 'three.example', 35, 330, device.ip, [null]), connection('unmapped', 'tproxy/tproxy-in', 'four.example', 50, 500, '192.168.50.99'), ] }; await service.refresh(); await service.refresh(); assert.deepEqual(service.snapshot().routes, [ { deviceId: id, source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' }, { deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' }, { deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: '20', downloadBytes: '200' }, ]); assert.deepEqual(service.snapshot().tracked, [ { source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' }, { source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' }, { source: 'gateway', outbound: 'vpn', uploadBytes: '70', downloadBytes: '700' }, ]); }); test('domain traffic accumulates connection deltas by device, service and source', async () => { let observedAt = new Date('2026-08-08T10:00:00.000Z'); let response = { connections: [ connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 10, 100), connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 20, 200), connection('hostless', 'tproxy/tproxy-in', '', 5, 50), connection('diagnostics', 'mixed/diagnostics-vpn-in', 'example.com', 30, 300), connection('unknown-device', 'tproxy/tproxy-in', 'example.net', 40, 400, '192.168.50.99'), ] }; const service = createDomainTrafficService({ observe: async () => response, devices: () => [device], now: () => observedAt, }); await service.refresh(); response = { connections: [ connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 15, 130), connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 22, 260), connection('hostless', 'tproxy/tproxy-in', '', 7, 70), ] }; observedAt = new Date('2026-08-08T10:00:02.000Z'); await service.refresh(); await service.refresh(); assert.deepEqual(service.snapshot().series, [ { deviceId: id, domain: 'chatgpt.com', service: 'OpenAI / ChatGPT', source: 'proxy', uploadBytes: '22', downloadBytes: '260', }, { deviceId: id, domain: 'googlevideo.com', service: 'YouTube', source: 'gateway', uploadBytes: '15', downloadBytes: '130', }, { deviceId: id, domain: '_unknown', service: 'Не распознано', source: 'gateway', uploadBytes: '7', downloadBytes: '70', }, ]); assert.deepEqual(service.snapshot().routes, [ { deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: '22', downloadBytes: '200', }, { deviceId: id, source: 'proxy', outbound: 'vpn', uploadBytes: '22', downloadBytes: '260', }, ]); assert.deepEqual(service.snapshot().attributionEvents, { unresolved_host: '1', unknown_device: '1', unsupported_source: '1', }); assert.equal(service.snapshot().observedAt, observedAt.toISOString()); assert.equal(service.snapshot().source.error, null); response = { connections: [] }; await service.refresh(); assert.equal(service.snapshot().series[1].downloadBytes, '130'); }); test('a hostless connection can become classified without counting its bytes twice', async () => { let response = { connections: [ connection('video', 'tproxy/tproxy-in', '', 10, 100), ] }; const service = createDomainTrafficService({ observe: async () => response, devices: () => [device], }); await service.refresh(); response = { connections: [ connection('video', 'tproxy/tproxy-in', 'r2.googlevideo.com', 15, 130), ] }; await service.refresh(); assert.deepEqual( Object.fromEntries(service.snapshot().series.map((series) => [series.domain, { uploadBytes: series.uploadBytes, downloadBytes: series.downloadBytes, }])), { _unknown: { uploadBytes: '10', downloadBytes: '100' }, 'googlevideo.com': { uploadBytes: '5', downloadBytes: '30' }, }, ); assert.deepEqual(service.snapshot().attributionEvents, { unresolved_host: '1', unknown_device: '0', unsupported_source: '0', }); }); test('domain traffic is bounded and keeps the last good snapshot on source failure', async () => { let fail = false; const service = createDomainTrafficService({ observe: async () => { if (fail) throw new Error('Clash API unavailable'); return { connections: [ connection('first', 'tproxy/tproxy-in', 'one.example', 1, 10), connection('second', 'tproxy/tproxy-in', 'two.example', 2, 20), ] }; }, devices: () => [device], maxSeries: 3, }); await service.refresh(); await service.refresh(); assert.deepEqual( Object.fromEntries(service.snapshot().series.map(({ domain, downloadBytes }) => [domain, downloadBytes])), { 'one.example': '10', _other: '20' }, ); assert.equal(service.snapshot().overflowConnections, '1'); assert.equal(service.snapshot().series.find(({ domain }) => domain === '_other').deviceId, '_other'); assert.ok(service.snapshot().series.length <= 3); fail = true; await assert.rejects(service.refresh(), /Clash API unavailable/); assert.equal(service.snapshot().series.find(({ domain }) => domain === 'one.example').downloadBytes, '10'); assert.equal(service.snapshot().source.error, 'Clash API unavailable'); }); test('an invalid connection rejects the whole snapshot without double-counting a retry', async () => { let response = { connections: [ connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100), { id: 'invalid', upload: -1, download: 0, metadata: {} }, ] }; const service = createDomainTrafficService({ observe: async () => response, devices: () => [device], }); await assert.rejects(service.refresh(), /невалидный domain traffic counter/); assert.deepEqual(service.snapshot().tracked, []); assert.deepEqual(service.snapshot().routes, []); assert.deepEqual(service.snapshot().series, []); response = { connections: [connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100)] }; await service.refresh(); assert.equal(service.snapshot().tracked[0].downloadBytes, '100'); assert.equal(service.snapshot().routes[0].downloadBytes, '100'); assert.equal(service.snapshot().series[0].downloadBytes, '100'); }); test('domain classification normalizes known services and rejects IP or malformed labels', () => { assert.deepEqual(classifyDomain('WWW.YouTube.com.'), { domain: 'youtube.com', service: 'YouTube' }); assert.deepEqual(classifyDomain('api.example.org'), { domain: 'api.example.org', service: 'api.example.org' }); assert.equal(classifyDomain('192.0.2.1'), null); assert.equal(classifyDomain('broken_label.example'), null); }); test('failover activity is zero-work while disabled and uses the existing connection poll', async () => { let observedAt = new Date('2026-08-19T10:00:00.000Z'); let response = { connections: [ connection('work', 'tproxy/tproxy-in', 'r1.googlevideo.com', 0, 0, device.ip, ['channel-primary', 'channel-selector']), connection('probe', 'mixed/diagnostics-primary-in', 'youtube.com', 0, 10_000, device.ip, ['channel-primary']), ] }; const service = createDomainTrafficService({ observe: async () => response, devices: () => [device], now: () => observedAt, }); await service.refresh(); assert.equal(service.activitySnapshot(100), null); service.enableActivity(); observedAt = new Date('2026-08-19T10:00:02.000Z'); response.connections[0].download = 2_000; response.connections[1].download = 99_000; await service.refresh(); const active = service.activitySnapshot(500); assert.equal(active.state, 'active'); assert.equal(active.totalBytesPerSecond, 1_000); assert.equal(active.transmittingConnections, 1); assert.equal(active.blockers[0].device, 'MacBook'); assert.equal(active.blockers[0].service, 'YouTube'); observedAt = new Date('2026-08-19T10:00:14.000Z'); await service.refresh(); assert.equal(service.activitySnapshot(500).state, 'quiet'); service.disableActivity(); assert.equal(service.activitySnapshot(500), null); }); test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => { let now = new Date('2026-08-31T10:00:00.000Z'); const service = createDomainTrafficService({ observe: () => ({ connections: [] }), devices: () => [], now: () => now, }); const initial = nativeConnection('native', '9007199254740993', '10'); service.ingestNative(nativeBatch([initial], { reset: true })); now = new Date('2026-08-31T10:00:01.000Z'); service.ingestNative(nativeBatch([initial], { reset: true, observedAt: now.toISOString(), })); assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993'); assert.equal(service.snapshot().source.activeConnections, 1); service.enableActivity(); now = new Date('2026-08-31T10:00:02.000Z'); service.ingestNative(nativeBatch([ nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }), ], { observedAt: now.toISOString(), closedIds: ['native'], })); assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998'); assert.equal(service.snapshot().series[0].downloadBytes, '15'); assert.equal(service.snapshot().source.activeConnections, 0); assert.equal(service.activitySnapshot(0).state, 'active'); now = new Date('2026-08-31T10:00:03.000Z'); service.ingestNative(nativeBatch([ nativeConnection('native', '9007199254740998', '15'), ], { reset: true, observedAt: now.toISOString(), })); assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998'); assert.equal(service.snapshot().series[0].downloadBytes, '15'); assert.equal(service.snapshot().source.activeConnections, 1); now = new Date('2026-08-31T10:00:14.000Z'); service.ingestNative(nativeBatch([], { observedAt: now.toISOString() })); assert.equal(service.snapshot().observedAt, now.toISOString()); assert.equal(service.snapshot().source.activeConnections, 1); assert.equal(service.activitySnapshot(0).state, 'quiet'); }); test('one native batch accounts every final tail before lifecycle and UI caps', () => { const service = createDomainTrafficService({ observe: () => ({ connections: [] }), devices: () => [], }); const connections = Array.from({ length: 2_049 }, (_, index) => ( nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' }) )); service.ingestNative(nativeBatch(connections, { reset: true, closedIds: connections.map(({ id: connectionId }) => connectionId), })); assert.equal(service.snapshot().series[0].uploadBytes, '2049'); assert.equal(service.snapshot().series[0].downloadBytes, '2049'); assert.equal(service.snapshot().source.activeConnections, 0); });