208 lines
7.8 KiB
JavaScript
208 lines
7.8 KiB
JavaScript
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 };
|
|
const connection = (connectionId, type, host, upload, download, sourceIP = device.ip, chains = ['vpn-out']) => ({
|
|
id: connectionId,
|
|
metadata: { type, host, sourceIP },
|
|
upload,
|
|
download,
|
|
chains,
|
|
});
|
|
|
|
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().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);
|
|
});
|