254 lines
9.4 KiB
JavaScript
254 lines
9.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
createLiveTrafficRoute,
|
|
enrichLiveTrafficDeviceLabels,
|
|
} from '../../dist/server/http/routes/liveTrafficRoute.js';
|
|
|
|
function response() {
|
|
return {
|
|
writeHead(status, headers) {
|
|
this.status = status;
|
|
this.headers = headers;
|
|
},
|
|
end(payload) {
|
|
this.rawPayload = payload;
|
|
this.payload = JSON.parse(payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
const snapshot = {
|
|
apiVersion: 1,
|
|
epoch: 'sing-box-1700000000000',
|
|
sequence: 7,
|
|
observedAt: '2026-08-31T10:00:00.000Z',
|
|
capabilities: {
|
|
lifecycle: true,
|
|
deviceAttribution: false,
|
|
applicationAttribution: false,
|
|
},
|
|
source: {
|
|
transport: 'native',
|
|
state: 'live',
|
|
completeness: 'lifecycle',
|
|
singBoxVersion: '1.14.0-rc.5',
|
|
singBoxApiVersion: 4,
|
|
error: null,
|
|
unattributedUploadBytes: '0',
|
|
unattributedDownloadBytes: '0',
|
|
},
|
|
summary: {
|
|
active: 0,
|
|
recent: 0,
|
|
visible: 0,
|
|
recognized: 0,
|
|
unresolved: 0,
|
|
unresolvedOrigin: 0,
|
|
truncated: false,
|
|
},
|
|
connections: [],
|
|
};
|
|
|
|
test('split Gateway control reads the cached socket while combined Gateway keeps no collector', () => {
|
|
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
|
|
|
assert.match(index, /const liveTraffic = clientLiveTraffic \|\| \(remoteDataplane \? \{[\s\S]*observeLiveTraffic\(\)[\s\S]*\} : null\)/);
|
|
assert.match(index, /createLiveTrafficRoute\(\{[\s\S]*traffic: liveTraffic,[\s\S]*deviceInventory: remoteDataplane \? deviceInventory : null/);
|
|
assert.match(index, /clientLiveTraffic\?\.start\(\)/);
|
|
assert.doesNotMatch(index, /liveTraffic\?\.start\(\)/);
|
|
});
|
|
|
|
test('GET returns the injected cached snapshot without another data-source operation', async () => {
|
|
let snapshots = 0;
|
|
const route = createLiveTrafficRoute({
|
|
traffic: {
|
|
snapshot() {
|
|
snapshots += 1;
|
|
return snapshot;
|
|
},
|
|
},
|
|
});
|
|
const res = response();
|
|
|
|
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live?ignored=1' }, res), true);
|
|
assert.equal(snapshots, 1);
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(res.headers, { 'content-type': 'application/json; charset=utf-8' });
|
|
assert.deepEqual(res.payload, snapshot);
|
|
});
|
|
|
|
test('async Gateway snapshots are validated and known dev labels are enriched without changing summary', async () => {
|
|
const gatewaySnapshot = {
|
|
...snapshot,
|
|
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
|
summary: {
|
|
active: 300,
|
|
recent: 0,
|
|
visible: 256,
|
|
recognized: 300,
|
|
unresolved: 0,
|
|
unresolvedOrigin: 299,
|
|
truncated: true,
|
|
},
|
|
connections: Array.from({ length: 256 }, (_, index) => ({
|
|
id: `connection-${String(index).padStart(3, '0')}`,
|
|
startedAt: new Date(Date.parse(snapshot.observedAt) - index * 1000).toISOString(),
|
|
closedAt: null,
|
|
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
|
network: 'tcp',
|
|
protocol: 'tls',
|
|
source: { ip: index === 0 ? '192.168.50.7' : '192.168.50.8', port: 50_000 + index },
|
|
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
|
origin: index === 0
|
|
? { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' }
|
|
: { kind: 'unknown', id: null, label: 'Неизвестное устройство', provenance: 'unknown' },
|
|
route: {
|
|
kind: 'vpn',
|
|
scope: 'local-sing-box',
|
|
outbound: 'proxy',
|
|
outboundType: 'selector',
|
|
chain: ['proxy'],
|
|
rule: 'default',
|
|
},
|
|
traffic: {
|
|
uploadBytes: '10',
|
|
downloadBytes: '20',
|
|
uploadBytesPerSecond: '1',
|
|
downloadBytesPerSecond: '2',
|
|
},
|
|
})),
|
|
};
|
|
const original = structuredClone(gatewaySnapshot);
|
|
const route = createLiveTrafficRoute({
|
|
traffic: { snapshot: async () => gatewaySnapshot },
|
|
deviceInventory: {
|
|
snapshot: () => ({
|
|
devices: [{
|
|
id: 'dev_0011223344556677',
|
|
alias: 'Гостиная',
|
|
hostname: 'tv.local',
|
|
ip: '192.168.50.7',
|
|
}],
|
|
}),
|
|
},
|
|
});
|
|
const res = response();
|
|
|
|
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live' }, res), true);
|
|
assert.equal(res.payload.connections[0].origin.label, 'Гостиная');
|
|
assert.equal(res.payload.connections[1].origin.kind, 'unknown');
|
|
assert.deepEqual(res.payload.summary, gatewaySnapshot.summary);
|
|
assert.deepEqual(gatewaySnapshot, original);
|
|
});
|
|
|
|
test('device label enrichment follows alias, hostname and IP without identifying unknown origins', () => {
|
|
const connection = {
|
|
id: 'connection-1',
|
|
startedAt: snapshot.observedAt,
|
|
closedAt: null,
|
|
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
|
network: 'tcp',
|
|
protocol: 'tls',
|
|
source: { ip: '192.168.50.7', port: 50_000 },
|
|
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
|
origin: { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' },
|
|
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'proxy', outboundType: 'selector', chain: ['proxy'], rule: 'default' },
|
|
traffic: { uploadBytes: '1', downloadBytes: '2', uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
|
|
};
|
|
const source = {
|
|
...snapshot,
|
|
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
|
summary: { ...snapshot.summary, active: 1, visible: 1, recognized: 1 },
|
|
connections: [connection],
|
|
};
|
|
const labels = (device) => enrichLiveTrafficDeviceLabels(source, { devices: [device] })
|
|
.connections[0].origin.label;
|
|
|
|
assert.equal(labels({ id: connection.origin.id, alias: ' ТВ ', hostname: 'tv.local', ip: '192.168.50.7' }), 'ТВ');
|
|
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: 'tv.local', ip: '192.168.50.7' }), 'tv.local');
|
|
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: null, ip: '192.168.50.7' }), '192.168.50.7');
|
|
assert.equal(labels({ id: 'dev_ffffffffffffffff', alias: 'Чужой', ip: connection.source.ip }), connection.origin.label);
|
|
const unknown = { ...source, connections: [{ ...connection, origin: { kind: 'unknown', id: null, label: 'Неизвестно', provenance: 'unknown' } }] };
|
|
assert.equal(enrichLiveTrafficDeviceLabels(unknown, { devices: [{ ...connection.origin, id: 'dev_0011223344556677', alias: 'Не угадывать' }] }).connections[0].origin.label, 'Неизвестно');
|
|
});
|
|
|
|
test('route rejects malformed cached snapshots before responding', async () => {
|
|
const route = createLiveTrafficRoute({
|
|
traffic: { snapshot: async () => ({ ...snapshot, apiVersion: 2 }) },
|
|
});
|
|
await assert.rejects(
|
|
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
|
/apiVersion 1/,
|
|
);
|
|
});
|
|
|
|
test('route ignores other paths and rejects mutation methods without reading the cache', async () => {
|
|
let snapshots = 0;
|
|
const route = createLiveTrafficRoute({
|
|
traffic: { snapshot: () => { snapshots += 1; return snapshot; } },
|
|
});
|
|
|
|
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history' }, response()), false);
|
|
await assert.rejects(
|
|
route.handle({ method: 'POST', url: '/api/traffic/live' }, response()),
|
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
|
);
|
|
assert.equal(snapshots, 0);
|
|
});
|
|
|
|
test('route is unavailable when no traffic collector exists', async () => {
|
|
const route = createLiveTrafficRoute({ traffic: null });
|
|
await assert.rejects(
|
|
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
test('PUT persists validated traffic settings with revision protection and returns canonical state', async () => {
|
|
let state = { revision: 4, traffic: { grouping: 'site', sort: 'popular', retentionSeconds: 10 } };
|
|
let sent = false;
|
|
const route = createLiveTrafficRoute({
|
|
traffic: null,
|
|
settingsState: {
|
|
read: () => state,
|
|
update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; },
|
|
},
|
|
readBody: async () => ({
|
|
settings: { grouping: 'device', sort: 'recent', retentionSeconds: 30 },
|
|
expectedRevision: 4,
|
|
}),
|
|
sendState: async (res) => { sent = true; res.end(JSON.stringify(state)); },
|
|
});
|
|
const res = response();
|
|
|
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/traffic/settings' }, res), true);
|
|
assert.equal(sent, true);
|
|
assert.deepEqual(state.traffic, { grouping: 'device', sort: 'recent', retentionSeconds: 30 });
|
|
assert.equal(state.revision, 5);
|
|
|
|
const conflict = createLiveTrafficRoute({
|
|
traffic: null,
|
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
|
readBody: async () => ({ settings: state.traffic, expectedRevision: 4 }),
|
|
sendState: async () => {},
|
|
});
|
|
await assert.rejects(
|
|
conflict.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
|
(error) => error.code === 'STATE_CONFLICT',
|
|
);
|
|
|
|
const invalid = createLiveTrafficRoute({
|
|
traffic: null,
|
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
|
readBody: async () => ({ settings: { grouping: 'guess' }, expectedRevision: 5 }),
|
|
sendState: async () => {},
|
|
});
|
|
await assert.rejects(
|
|
invalid.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
|
(error) => error.code === 'REQUEST_INVALID',
|
|
);
|
|
});
|