Files
harbor-net/test/server/device-inventory.test.js
T
dokril 307ad02cd7
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s
Track per-device traffic totals and recover inventory state
2026-08-07 15:24:54 +03:00

346 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/adapters/neighbors.js';
import {
createDeviceInventoryService,
createVendorLookup,
DEVICE_INVENTORY_SCHEMA_VERSION,
migrateDeviceInventoryState,
} from '../../src/server/services/deviceInventoryService.js';
import { createJsonStore } from '../../src/server/services/stateStore.js';
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
const ouiPath = path.join(directory, 'oui.txt');
fs.writeFileSync(ouiPath, '00-11-22 (hex)\t\tExample Devices\n');
const vendor = createVendorLookup(ouiPath);
assert.equal(vendor('00:11:22:33:44:55'), 'Example Devices');
assert.equal(vendor('02:11:22:33:44:55'), null);
let current = new Date('2026-07-01T10:00:00.000Z');
let observation = {
observedAt: current.toISOString(),
observations: parseNeighborSnapshot([
{ dst: '2001:db8::10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
{ dst: '192.168.50.11', dev: 'br0', state: ['INCOMPLETE'] },
], current.toISOString()),
error: null,
};
let observeCalls = 0;
const service = createDeviceInventoryService({
store,
observe: async () => {
observeCalls += 1;
return observation;
},
vendor,
now: () => current,
});
const [firstRefresh, sharedRefresh] = await Promise.all([service.refresh(), service.refresh()]);
let snapshot = firstRefresh;
assert.strictEqual(sharedRefresh, firstRefresh);
assert.equal(observeCalls, 1);
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
assert.equal(snapshot.devices[0].status, 'online');
assert.equal(snapshot.devices[0].confidence, 'high');
assert.equal(snapshot.devices[0].interface, 'br0');
observation = {
observedAt: current.toISOString(),
observations: parseNeighborSnapshot([
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
{ dst: '192.168.50.12', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
], current.toISOString()),
error: null,
};
snapshot = await service.refresh();
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].confidence, 'ambiguous');
current = new Date('2026-07-01T10:05:00.000Z');
observation = {
observedAt: current.toISOString(),
observations: parseNeighborSnapshot([
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
], current.toISOString()),
error: null,
};
const firstSeen = snapshot.devices[0].lastSeenAt;
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].lastSeenAt, firstSeen);
assert.equal(snapshot.devices[0].status, 'recent');
snapshot = service.update(snapshot.devices[0].id, { alias: 'Телевизор', pinned: true }, snapshot.revision);
const restarted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current });
assert.deepEqual(restarted.snapshot().devices[0], snapshot.devices[0]);
assert.throws(
() => restarted.update(snapshot.devices[0].id, { pinned: false }, snapshot.revision - 1),
(error) => error.code === 'STATE_CONFLICT',
);
observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
snapshot = await restarted.refresh();
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.source.error, 'source unavailable');
snapshot = restarted.update(snapshot.devices[0].id, { alias: '', pinned: false }, snapshot.revision);
current = new Date('2026-08-02T10:00:00.000Z');
observation = { observedAt: current.toISOString(), observations: [], error: null };
snapshot = await restarted.refresh();
assert.equal(snapshot.devices.length, 0);
const failed = readNeighborSnapshot(() => ({ status: 1, stderr: 'not available' }), () => current);
assert.deepEqual(failed.observations, []);
assert.match(failed.error, /not available/);
});
test('device traffic totals persist exact deltas across polls and process epochs', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-traffic-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({
filePath: path.join(directory, 'devices.json'),
defaultValue: {},
migrate: migrateDeviceInventoryState,
});
const observedAt = '2026-08-07T12:00:00.000Z';
const mac = '00:11:22:33:44:55';
const neighbor = {
observedAt,
observations: [{
ip: '192.168.50.7',
mac,
interface: 'eth0',
observedAt,
active: true,
}],
error: null,
};
let traffic = {
epoch: 'epoch-a',
generation: 'rules-a',
observedAt,
source: { error: null },
devices: [{
ip: '192.168.50.7',
mac,
interface: 'eth0',
uploadBytes: '9007199254740993',
downloadBytes: '100',
}],
};
let trafficError = null;
const createService = () => createDeviceInventoryService({
store,
observe: () => neighbor,
observeTraffic: () => {
if (trafficError) throw trafficError;
return traffic;
},
});
let service = createService();
let snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
assert.equal(snapshot.devices[0].downloadBytes, '100');
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt);
assert.deepEqual(snapshot.source.traffic, { lastObservedAt: observedAt, error: null });
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
assert.equal(snapshot.devices[0].downloadBytes, '100');
traffic = {
...traffic,
devices: [{ ...traffic.devices[0], uploadBytes: '9007199254740995', downloadBytes: '150' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
assert.equal(snapshot.devices[0].downloadBytes, '150');
service = createService();
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
assert.equal(snapshot.devices[0].downloadBytes, '150');
traffic = {
...traffic,
epoch: 'epoch-b',
generation: 'rules-b',
devices: [{ ...traffic.devices[0], uploadBytes: '10', downloadBytes: '20' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.equal(snapshot.devices[0].downloadBytes, '170');
traffic = {
...traffic,
devices: [{ ...traffic.devices[0], uploadBytes: '9', downloadBytes: '20' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.match(snapshot.source.traffic.error, /уменьшился/);
trafficError = new Error('traffic unavailable');
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
});
test('device inventory v1 migration creates a versioned backup', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filePath = path.join(directory, 'devices.json');
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, revision: 4, devices: [] }));
const store = createJsonStore({
filePath,
defaultValue: {},
migrate: migrateDeviceInventoryState,
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
assert.deepEqual(migrated.traffic.baselinesByMac, {});
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
assert.ok(fs.existsSync(store.migration.backupPath));
});
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filePath = path.join(directory, 'devices.json');
const observedAt = '2026-08-07T12:00:00.000Z';
const macs = [
'00:11:22:33:44:55',
'00:11:22:33:44:66',
'00:11:22:33:44:77',
'00:11:22:33:44:88',
'00:11:22:33:44:99',
];
const [firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac] = macs;
const devices = macs.map((mac, index) => ({
id: `device-${index}`,
alias: '',
pinned: false,
hostname: null,
manufacturer: null,
mac,
ip: `192.168.50.${index + 7}`,
interface: 'eth0',
firstSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
lastSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
source: 'neighbor',
confidence: 'high',
}));
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 2,
revision: 4,
lastObservedAt: observedAt,
lastError: null,
devices,
traffic: {
epoch: 'epoch-a',
generation: 'rules-a',
lastObservedAt: observedAt,
lastError: null,
baselinesByMac: {
[firstMac]: { epoch: 'epoch-a', uploadBytes: 'broken', downloadBytes: '100' },
[secondMac]: { epoch: 'epoch-a', uploadBytes: '50', downloadBytes: '60' },
[missingTotalMac]: { epoch: 'epoch-a', uploadBytes: '90', downloadBytes: '100' },
[expiredMac]: { epoch: 'epoch-a', uploadBytes: '110', downloadBytes: '120' },
},
totalsByMac: {
[firstMac]: { uploadBytes: '500', downloadBytes: '600', observedAt },
[secondMac]: { uploadBytes: 'broken', downloadBytes: '700', observedAt },
[missingBaselineMac]: { uploadBytes: '800', downloadBytes: '900', observedAt },
[expiredMac]: { uploadBytes: 'broken', downloadBytes: '1000', observedAt },
},
},
}));
const store = createJsonStore({
filePath,
defaultValue: {},
migrate: migrateDeviceInventoryState,
backupWhen: () => true,
});
const migrated = store.read();
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
assert.ok(fs.existsSync(store.migration.backupPath));
assert.deepEqual(migrated.traffic.totalsByMac[firstMac], {
uploadBytes: '500',
downloadBytes: '600',
observedAt,
});
assert.equal(migrated.traffic.totalsByMac[secondMac], undefined);
assert.deepEqual(
new Set(migrated.traffic.rebaselineMacs),
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
);
let counters = [
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
{ mac: secondMac, uploadBytes: '70', downloadBytes: '80' },
{ mac: missingBaselineMac, uploadBytes: '110', downloadBytes: '120' },
{ mac: missingTotalMac, uploadBytes: '130', downloadBytes: '140' },
];
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: devices.filter(({ mac }) => mac !== expiredMac)
.map(({ ip, mac, interface: deviceInterface }) => ({
ip,
mac,
interface: deviceInterface,
observedAt,
active: true,
})),
}),
observeTraffic: () => ({
epoch: 'epoch-a',
generation: 'rules-a',
observedAt,
source: { error: null },
devices: counters,
}),
});
let snapshot = await service.refresh();
let byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
assert.equal(byMac.get(firstMac).uploadBytes, '500');
assert.equal(byMac.get(secondMac).uploadBytes, '0');
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
assert.equal(byMac.has(expiredMac), false);
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
assert.equal(snapshot.source.traffic.error, null);
counters = [
{ mac: firstMac, uploadBytes: '250', downloadBytes: '330' },
{ mac: secondMac, uploadBytes: '75', downloadBytes: '90' },
{ mac: missingBaselineMac, uploadBytes: '115', downloadBytes: '125' },
{ mac: missingTotalMac, uploadBytes: '150', downloadBytes: '160' },
];
snapshot = await service.refresh();
byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
assert.equal(byMac.get(firstMac).uploadBytes, '550');
assert.equal(byMac.get(firstMac).downloadBytes, '630');
assert.equal(byMac.get(secondMac).uploadBytes, '5');
assert.equal(byMac.get(secondMac).downloadBytes, '10');
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '805');
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
});