1181 lines
45 KiB
JavaScript
1181 lines
45 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 '../../dist/server/adapters/neighbors.js';
|
|
import {
|
|
createDeviceInventoryService,
|
|
createVendorLookup,
|
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
|
deviceId,
|
|
migrateDeviceInventoryState,
|
|
} from '../../dist/server/services/deviceInventoryService.js';
|
|
import { fingerprintDirectDevices } from '../../dist/server/services/devicePolicyService.js';
|
|
import { createJsonStore } from '../../dist/server/services/stateStore.js';
|
|
|
|
test('container neighbors are hidden while a real 172 LAN device remains valid', () => {
|
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
|
const parsed = parseNeighborSnapshot([
|
|
{ dst: '172.17.0.2', dev: 'docker0', lladdr: '00:11:22:33:44:51', state: ['REACHABLE'] },
|
|
{ dst: '172.18.0.2', dev: 'br-1234', lladdr: '00:11:22:33:44:52', state: ['REACHABLE'] },
|
|
{ dst: '172.19.0.2', dev: 'veth1234', lladdr: '00:11:22:33:44:53', state: ['REACHABLE'] },
|
|
{ dst: '172.20.0.7', dev: 'eth0', lladdr: '00:11:22:33:44:54', state: ['REACHABLE'] },
|
|
], observedAt);
|
|
assert.deepEqual(parsed.map(({ ip, interface: deviceInterface }) => [ip, deviceInterface]), [
|
|
['172.20.0.7', 'eth0'],
|
|
]);
|
|
|
|
const storedDevice = (ip, deviceInterface) => ({
|
|
id: `dev_${ip.replaceAll('.', '').padEnd(16, '0').slice(0, 16)}`,
|
|
alias: '',
|
|
pinned: false,
|
|
hostname: null,
|
|
manufacturer: null,
|
|
mac: ip === '172.17.0.2' ? '00:11:22:33:44:51' : '00:11:22:33:44:54',
|
|
ip,
|
|
interface: deviceInterface,
|
|
firstSeenAt: observedAt,
|
|
lastSeenAt: observedAt,
|
|
source: 'neighbor',
|
|
confidence: 'high',
|
|
});
|
|
const migrated = migrateDeviceInventoryState({
|
|
schemaVersion: 2,
|
|
devices: [storedDevice('172.17.0.2', 'docker0'), storedDevice('172.20.0.7', 'eth0')],
|
|
traffic: { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [] },
|
|
});
|
|
assert.deepEqual(migrated.devices.map(({ ip }) => ip), ['172.20.0.7']);
|
|
});
|
|
|
|
test('malformed persisted devices and remote observations cannot enter canonical inventory', async (t) => {
|
|
const observedAt = '2026-08-08T12:00:00.000Z';
|
|
const mac = '00:11:22:33:44:55';
|
|
const persisted = {
|
|
id: 'legacy-device-id',
|
|
alias: 42,
|
|
pinned: 'yes',
|
|
hostname: 42,
|
|
manufacturer: [],
|
|
mac,
|
|
ip: '192.168.50.7',
|
|
interface: 'eth0',
|
|
firstSeenAt: observedAt,
|
|
lastSeenAt: observedAt,
|
|
source: null,
|
|
confidence: 'untrusted',
|
|
};
|
|
const migrated = migrateDeviceInventoryState({
|
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
|
devices: [
|
|
persisted,
|
|
{ ...persisted, mac: 'invalid' },
|
|
{ ...persisted, mac: '00:11:22:33:44:66', ip: 'not-an-ip' },
|
|
{ ...persisted, mac: '00:11:22:33:44:77', lastSeenAt: 'not-a-date' },
|
|
],
|
|
});
|
|
assert.deepEqual(migrated.devices, [{
|
|
...persisted,
|
|
id: deviceId(mac),
|
|
alias: '',
|
|
pinned: false,
|
|
deprioritized: false,
|
|
hostname: null,
|
|
manufacturer: null,
|
|
source: 'neighbor',
|
|
confidence: 'high',
|
|
}]);
|
|
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-guard-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({
|
|
filePath: path.join(directory, 'devices.json'),
|
|
defaultValue: {},
|
|
migrate: migrateDeviceInventoryState,
|
|
});
|
|
store.remove();
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: [
|
|
{ ip: '192.168.50.8', mac: '00:11:22:33:44:88', interface: 'eth0', observedAt, active: true },
|
|
{ ip: 'bad', mac: '00:11:22:33:44:99', interface: 'eth0', observedAt, active: true },
|
|
{ ip: '192.168.50.9', mac: 'invalid', interface: 'eth0', observedAt, active: true },
|
|
{ ip: '192.168.50.10', mac: '00:11:22:33:44:aa', interface: 'docker0', observedAt, active: true },
|
|
{ ip: '192.168.50.11', mac: '00:11:22:33:44:bb', interface: 'eth0', observedAt, active: 'yes' },
|
|
{ ip: '192.168.50.12', mac: '00:11:22:33:44:cc', interface: 'eth0', observedAt: 'bad', active: true },
|
|
],
|
|
}),
|
|
now: () => new Date(observedAt),
|
|
});
|
|
const snapshot = await service.refresh();
|
|
assert.deepEqual(snapshot.devices.map(({ mac: deviceMac, ip }) => [deviceMac, ip]), [
|
|
['00:11:22:33:44:88', '192.168.50.8'],
|
|
]);
|
|
});
|
|
|
|
test('device inventory resolves hostnames without making reverse lookup a refresh dependency', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-hostname-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
|
|
const observedAt = '2026-08-11T12:00:00.000Z';
|
|
const mac = '00:11:22:33:44:55';
|
|
let ip = '192.168.50.10';
|
|
let lookupFails = false;
|
|
const lookups = [];
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: [{ ip, mac, interface: 'br0', observedAt, active: true }],
|
|
}),
|
|
resolveHostname: async (address) => {
|
|
lookups.push(address);
|
|
if (lookupFails) throw new Error('reverse DNS unavailable');
|
|
return 'living-room.local.';
|
|
},
|
|
now: () => new Date(observedAt),
|
|
});
|
|
|
|
let snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
|
|
snapshot = await service.refresh();
|
|
assert.deepEqual(lookups, ['192.168.50.10']);
|
|
|
|
ip = '192.168.50.11';
|
|
lookupFails = true;
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].ip, ip);
|
|
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
|
|
assert.deepEqual(lookups, ['192.168.50.10', '192.168.50.11']);
|
|
});
|
|
|
|
test('device inventory discovers, merges, persists metadata and expires devices after 30 days', 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');
|
|
assert.equal(snapshot.devices[0].ip, '192.168.50.10');
|
|
|
|
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 namedId = snapshot.devices[0].id;
|
|
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',
|
|
);
|
|
|
|
current = new Date('2026-07-01T10:06:00.000Z');
|
|
observation = {
|
|
observedAt: current.toISOString(),
|
|
observations: parseNeighborSnapshot([
|
|
{ dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
|
|
], current.toISOString()),
|
|
error: null,
|
|
};
|
|
snapshot = await restarted.refresh();
|
|
assert.equal(snapshot.devices.length, 1);
|
|
assert.equal(snapshot.devices[0].id, namedId);
|
|
assert.equal(snapshot.devices[0].ip, '192.168.50.18');
|
|
assert.equal(snapshot.devices[0].alias, 'Телевизор');
|
|
assert.equal(snapshot.devices[0].pinned, true);
|
|
|
|
observation = {
|
|
observedAt: current.toISOString(),
|
|
observations: parseNeighborSnapshot([
|
|
{ dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:66', state: ['REACHABLE'] },
|
|
], current.toISOString()),
|
|
error: null,
|
|
};
|
|
snapshot = await restarted.refresh();
|
|
const replacement = snapshot.devices.find(({ mac: deviceMac }) => deviceMac === '00:11:22:33:44:66');
|
|
assert.notEqual(replacement.id, namedId);
|
|
assert.equal(replacement.alias, '');
|
|
assert.equal(replacement.pinned, false);
|
|
|
|
snapshot = restarted.update(namedId, { deprioritized: true }, snapshot.revision);
|
|
const deprioritized = snapshot.devices.find(({ id }) => id === namedId);
|
|
assert.equal(deprioritized.pinned, false);
|
|
assert.equal(deprioritized.deprioritized, true);
|
|
assert.throws(
|
|
() => restarted.update(namedId, { pinned: true, deprioritized: true }, snapshot.revision),
|
|
(error) => error.code === 'REQUEST_INVALID',
|
|
);
|
|
const persisted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current });
|
|
snapshot = persisted.snapshot();
|
|
assert.equal(snapshot.devices.find(({ id }) => id === namedId).deprioritized, true);
|
|
snapshot = persisted.update(replacement.id, { pinned: true }, snapshot.revision);
|
|
assert.equal(snapshot.devices.find(({ id }) => id === replacement.id).pinned, true);
|
|
|
|
observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
|
|
snapshot = await persisted.refresh();
|
|
assert.equal(snapshot.devices.length, 2);
|
|
assert.equal(snapshot.source.error, 'source unavailable');
|
|
|
|
current = new Date('2026-08-02T10:00:00.000Z');
|
|
observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
|
|
snapshot = await persisted.refresh();
|
|
assert.equal(snapshot.devices.length, 2);
|
|
assert.equal(snapshot.source.error, 'source unavailable');
|
|
|
|
observation = { observedAt: current.toISOString(), observations: [], error: null };
|
|
snapshot = await persisted.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',
|
|
proxyUploadBytes: '1000',
|
|
proxyDownloadBytes: '2000',
|
|
}],
|
|
};
|
|
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.equal(snapshot.devices[0].proxyUploadBytes, '1000');
|
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2000');
|
|
assert.deepEqual(snapshot.source.traffic, {
|
|
lastObservedAt: observedAt,
|
|
error: null,
|
|
proxy: { lastObservedAt: observedAt, error: null },
|
|
});
|
|
assert.deepEqual(snapshot.traffic, {
|
|
gatewayBytes: '9007199254741093',
|
|
proxyBytes: '3000',
|
|
uploadBytes: '9007199254741993',
|
|
downloadBytes: '2100',
|
|
totalBytes: '9007199254744093',
|
|
gatewayObservedAt: observedAt,
|
|
proxyObservedAt: observedAt,
|
|
observedAt,
|
|
history: [],
|
|
});
|
|
|
|
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',
|
|
proxyUploadBytes: '1010',
|
|
proxyDownloadBytes: '2050',
|
|
}],
|
|
};
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
|
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '1010');
|
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
|
|
assert.equal(snapshot.traffic.gatewayBytes, '9007199254741145');
|
|
assert.equal(snapshot.traffic.proxyBytes, '3060');
|
|
assert.equal(snapshot.traffic.uploadBytes, '9007199254742005');
|
|
assert.equal(snapshot.traffic.downloadBytes, '2200');
|
|
|
|
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',
|
|
proxyUploadBytes: '3',
|
|
proxyDownloadBytes: '4',
|
|
}],
|
|
};
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
|
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
|
assert.equal(snapshot.traffic.totalBytes, '9007199254744242');
|
|
|
|
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, /уменьшился/);
|
|
|
|
traffic = {
|
|
...traffic,
|
|
observedAt: '2026-08-07T12:00:15.000Z',
|
|
devices: [{
|
|
...traffic.devices[0],
|
|
uploadBytes: '12',
|
|
downloadBytes: '25',
|
|
proxyUploadBytes: '2',
|
|
proxyDownloadBytes: '4',
|
|
}],
|
|
};
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
|
assert.equal(snapshot.devices[0].downloadBytes, '175');
|
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
|
assert.equal(snapshot.source.traffic.error, null);
|
|
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
|
assert.equal(snapshot.traffic.proxyBytes, '3067');
|
|
assert.equal(snapshot.traffic.gatewayObservedAt, '2026-08-07T12:00:15.000Z');
|
|
assert.equal(snapshot.traffic.proxyObservedAt, observedAt);
|
|
assert.equal(snapshot.traffic.observedAt, observedAt);
|
|
|
|
trafficError = new Error('traffic unavailable');
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
|
assert.equal(snapshot.source.traffic.lastObservedAt, '2026-08-07T12:00:15.000Z');
|
|
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
|
assert.equal(snapshot.traffic.totalBytes, '9007199254744249');
|
|
});
|
|
|
|
test('device traffic history stays in bounded service memory and survives client snapshots', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-history-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, 'devices.json');
|
|
const store = createJsonStore({ filePath, defaultValue: {}, migrate: migrateDeviceInventoryState });
|
|
const mac = '00:11:22:33:44:55';
|
|
let observedAt = '2026-08-07T12:00:00.000Z';
|
|
let uploadBytes = '100';
|
|
let proxyDownloadBytes = '20';
|
|
const createService = () => createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
|
}),
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a',
|
|
generation: 'rules-a',
|
|
observedAt,
|
|
source: { error: null },
|
|
devices: [{
|
|
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes, downloadBytes: '0',
|
|
proxyUploadBytes: '0', proxyDownloadBytes,
|
|
}],
|
|
}),
|
|
});
|
|
const service = createService();
|
|
assert.deepEqual((await service.refresh()).devices[0].trafficHistory, []);
|
|
|
|
observedAt = '2026-08-07T12:00:15.000Z';
|
|
uploadBytes = '130';
|
|
proxyDownloadBytes = '25';
|
|
const snapshot = await service.refresh();
|
|
assert.equal(snapshot.trafficHistoryCapacity, 120);
|
|
assert.deepEqual(snapshot.devices[0].trafficHistory, [{
|
|
observedAt,
|
|
gatewayBytes: '30',
|
|
proxyBytes: '5',
|
|
uploadBytes: '30',
|
|
downloadBytes: '5',
|
|
}]);
|
|
assert.deepEqual(snapshot.traffic.history, [{
|
|
observedAt,
|
|
gatewayBytes: '30',
|
|
proxyBytes: '5',
|
|
uploadBytes: '30',
|
|
downloadBytes: '5',
|
|
}]);
|
|
assert.deepEqual(service.snapshot().devices[0].trafficHistory, snapshot.devices[0].trafficHistory);
|
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /trafficHistory/);
|
|
assert.deepEqual(createService().snapshot().devices[0].trafficHistory, []);
|
|
assert.deepEqual(createService().snapshot().traffic.history, []);
|
|
});
|
|
|
|
test('device outbound history keeps sing-box routes and kernel Direct on separate reset-safe series', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-outbound-history-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, 'devices.json');
|
|
const store = createJsonStore({ filePath, defaultValue: {}, migrate: migrateDeviceInventoryState });
|
|
const mac = '00:11:22:33:44:55';
|
|
const id = deviceId(mac);
|
|
let observedAt = '2026-08-12T12:00:00.000Z';
|
|
let epoch = 'epoch-a';
|
|
let vpn = ['100', '200'];
|
|
let trackedDirect = ['10', '20'];
|
|
let unknown = ['1', '2'];
|
|
let directIpv4 = ['40', '60'];
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
|
}),
|
|
observeTraffic: () => ({
|
|
epoch,
|
|
generation: 'rules-a',
|
|
observedAt,
|
|
source: { error: null },
|
|
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
|
|
devices: [{
|
|
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: '100', downloadBytes: '200',
|
|
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
|
|
}],
|
|
}),
|
|
observeDomainTraffic: () => ({
|
|
epoch,
|
|
observedAt,
|
|
source: { error: null },
|
|
routes: [
|
|
{ deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: vpn[0], downloadBytes: vpn[1] },
|
|
{ deviceId: id, source: 'proxy', outbound: 'direct', uploadBytes: trackedDirect[0], downloadBytes: trackedDirect[1] },
|
|
{ deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: unknown[0], downloadBytes: unknown[1] },
|
|
],
|
|
series: [],
|
|
}),
|
|
});
|
|
|
|
assert.deepEqual((await service.refresh()).devices[0].outboundTrafficHistory, []);
|
|
observedAt = '2026-08-12T12:00:15.000Z';
|
|
vpn = ['150', '250'];
|
|
trackedDirect = ['30', '50'];
|
|
unknown = ['4', '8'];
|
|
directIpv4 = ['70', '90'];
|
|
let snapshot = await service.refresh();
|
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{
|
|
observedAt,
|
|
vpnBytes: '100',
|
|
directTrackedBytes: '50',
|
|
directIpv4Bytes: '60',
|
|
unknownBytes: '9',
|
|
}]);
|
|
|
|
observedAt = '2026-08-12T12:00:30.000Z';
|
|
epoch = 'epoch-b';
|
|
vpn = ['3', '4'];
|
|
trackedDirect = ['1', '2'];
|
|
unknown = ['0', '1'];
|
|
directIpv4 = ['5', '6'];
|
|
snapshot = await service.refresh();
|
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), {
|
|
observedAt,
|
|
vpnBytes: '0',
|
|
directTrackedBytes: '0',
|
|
directIpv4Bytes: '0',
|
|
unknownBytes: '0',
|
|
});
|
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/);
|
|
});
|
|
|
|
test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-global-traffic-retention-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({
|
|
filePath: path.join(directory, 'devices.json'),
|
|
defaultValue: {},
|
|
migrate: migrateDeviceInventoryState,
|
|
});
|
|
const mac = '00:11:22:33:44:55';
|
|
let observedAt = '2026-07-01T12:00:00.000Z';
|
|
let present = true;
|
|
let uploadBytes = '100';
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: present
|
|
? [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }]
|
|
: [],
|
|
}),
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null },
|
|
devices: [{ mac, uploadBytes, downloadBytes: '0' }],
|
|
}),
|
|
});
|
|
|
|
assert.equal((await service.refresh()).traffic.totalBytes, '100');
|
|
present = false;
|
|
observedAt = '2026-08-07T12:00:00.000Z';
|
|
let snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices.length, 0);
|
|
assert.equal(snapshot.traffic.totalBytes, '100');
|
|
present = true;
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.traffic.totalBytes, '100');
|
|
uploadBytes = '110';
|
|
assert.equal((await service.refresh()).traffic.totalBytes, '110');
|
|
});
|
|
|
|
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-legacy-'));
|
|
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,
|
|
error: null,
|
|
observations: [
|
|
{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true },
|
|
{ ip: '172.17.0.2', mac: '00:11:22:33:44:66', interface: 'docker0', observedAt, active: true },
|
|
],
|
|
};
|
|
let row = {
|
|
mac,
|
|
uploadBytes: '10',
|
|
downloadBytes: '20',
|
|
proxyUploadBytes: '30',
|
|
proxyDownloadBytes: '40',
|
|
};
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => neighbor,
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null }, devices: [row],
|
|
}),
|
|
});
|
|
|
|
let snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices.length, 1);
|
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
|
row = { mac, uploadBytes: '15', downloadBytes: '27' };
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].uploadBytes, '15');
|
|
assert.equal(snapshot.devices[0].downloadBytes, '27');
|
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '40');
|
|
assert.equal(snapshot.source.traffic.proxy.error, null);
|
|
});
|
|
|
|
test('a proxy regression rejects every device in that proxy sample atomically', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-atomic-'));
|
|
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 macs = ['00:11:22:33:44:55', '00:11:22:33:44:66'];
|
|
const neighbor = {
|
|
observedAt,
|
|
error: null,
|
|
observations: macs.map((mac, index) => ({
|
|
ip: `192.168.50.${index + 7}`, mac, interface: 'eth0', observedAt, active: true,
|
|
})),
|
|
};
|
|
let rows = [
|
|
{ mac: macs[0], uploadBytes: '10', downloadBytes: '20', proxyUploadBytes: '30', proxyDownloadBytes: '40' },
|
|
{ mac: macs[1], uploadBytes: '50', downloadBytes: '60', proxyUploadBytes: '70', proxyDownloadBytes: '80' },
|
|
];
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => neighbor,
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null }, devices: rows,
|
|
}),
|
|
});
|
|
await service.refresh();
|
|
const before = structuredClone(store.read().traffic.proxy);
|
|
rows = [
|
|
{ mac: macs[0], uploadBytes: '11', downloadBytes: '22', proxyUploadBytes: '35', proxyDownloadBytes: '45' },
|
|
{ mac: macs[1], uploadBytes: '52', downloadBytes: '63', proxyUploadBytes: '69', proxyDownloadBytes: '80' },
|
|
];
|
|
const snapshot = await service.refresh();
|
|
const after = store.read().traffic.proxy;
|
|
assert.deepEqual(after.baselinesByMac, before.baselinesByMac);
|
|
assert.deepEqual(after.totalsByMac, before.totalsByMac);
|
|
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
|
assert.equal(snapshot.devices.find(({ mac }) => mac === macs[0]).uploadBytes, '11');
|
|
});
|
|
|
|
test('device policy is independent from pinning, persists, and keeps the last applied mode on failure', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({
|
|
filePath: path.join(directory, 'devices.json'),
|
|
defaultValue: {},
|
|
migrate: migrateDeviceInventoryState,
|
|
initializeMissing: true,
|
|
backupWhen: () => true,
|
|
});
|
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
|
const mac = '00:11:22:33:44:55';
|
|
let observations = [{
|
|
ip: '192.168.50.7',
|
|
mac,
|
|
interface: 'eth0',
|
|
observedAt,
|
|
active: true,
|
|
}];
|
|
let activeDevices = [];
|
|
let generation = 0;
|
|
let policyEpoch = 'policy-epoch-a';
|
|
let failApply = false;
|
|
let malformedAck = false;
|
|
const appliedSets = [];
|
|
const policySnapshot = () => ({
|
|
epoch: policyEpoch,
|
|
generation: `policy-rules-${generation}`,
|
|
fingerprint: fingerprintDirectDevices(activeDevices),
|
|
observedAt,
|
|
appliedIds: activeDevices.map(({ id }) => id),
|
|
});
|
|
const createService = () => createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({ observedAt, observations, error: null }),
|
|
observePolicy: policySnapshot,
|
|
applyPolicies: async (devices) => {
|
|
appliedSets.push(structuredClone(devices));
|
|
if (failApply) throw new Error('iptables unavailable');
|
|
if (malformedAck) return { ...policySnapshot(), fingerprint: 'invalid' };
|
|
activeDevices = structuredClone(devices);
|
|
generation += 1;
|
|
return policySnapshot();
|
|
},
|
|
});
|
|
|
|
let service = createService();
|
|
let snapshot = await service.refresh();
|
|
const id = snapshot.devices[0].id;
|
|
assert.equal(snapshot.devices[0].desiredPolicy, 'vpn');
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
|
|
|
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
|
assert.deepEqual(appliedSets.at(-1), [{ id, ip: '192.168.50.7', mac, interface: 'eth0' }]);
|
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
|
assert.equal(store.read().schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
|
assert.equal(store.read().policy.schemaVersion, 1);
|
|
|
|
service = createService();
|
|
snapshot = await service.reconcilePolicies();
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
|
snapshot = service.update(id, { pinned: true }, snapshot.revision);
|
|
snapshot = service.update(id, { pinned: false }, snapshot.revision);
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
|
|
|
policyEpoch = 'policy-epoch-b';
|
|
activeDevices = [];
|
|
failApply = true;
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
|
assert.equal(snapshot.devices[0].policyStatus, 'failed');
|
|
failApply = false;
|
|
snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
|
|
|
observations = [
|
|
{ ...observations[0], interface: 'eth0' },
|
|
{ ...observations[0], interface: 'eth1' },
|
|
];
|
|
snapshot = await service.refresh();
|
|
assert.deepEqual(appliedSets.at(-1), []);
|
|
assert.equal(snapshot.devices[0].confidence, 'ambiguous');
|
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
|
assert.equal(snapshot.devices[0].policyStatus, 'pending');
|
|
|
|
snapshot = await service.setPolicy(id, 'vpn', snapshot.revision);
|
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
|
observations = [{ ...observations[0], interface: 'eth0' }];
|
|
snapshot = await service.refresh();
|
|
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
|
const secondMac = '00:11:22:33:44:66';
|
|
observations.push({
|
|
ip: '192.168.50.8',
|
|
mac: secondMac,
|
|
interface: 'eth0',
|
|
observedAt,
|
|
active: true,
|
|
});
|
|
snapshot = await service.refresh();
|
|
const secondId = snapshot.devices.find((device) => device.mac === secondMac).id;
|
|
failApply = true;
|
|
await assert.rejects(
|
|
service.setPolicy(secondId, 'direct', snapshot.revision),
|
|
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
|
);
|
|
snapshot = service.snapshot();
|
|
const byId = new Map(snapshot.devices.map((device) => [device.id, device]));
|
|
assert.equal(byId.get(id).desiredPolicy, 'direct');
|
|
assert.equal(byId.get(id).appliedPolicy, 'direct');
|
|
assert.equal(byId.get(id).policyStatus, 'applied');
|
|
assert.equal(byId.get(secondId).desiredPolicy, 'direct');
|
|
assert.equal(byId.get(secondId).appliedPolicy, 'vpn');
|
|
assert.equal(byId.get(secondId).policyStatus, 'failed');
|
|
assert.equal(snapshot.source.policy.error, 'iptables unavailable');
|
|
failApply = false;
|
|
malformedAck = true;
|
|
await assert.rejects(
|
|
service.setPolicy(secondId, 'direct', snapshot.revision),
|
|
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
|
);
|
|
snapshot = service.snapshot();
|
|
assert.equal(snapshot.devices.find((device) => device.id === secondId).appliedPolicy, 'vpn');
|
|
assert.match(snapshot.source.policy.error, /acknowledgement/);
|
|
assert.throws(
|
|
() => service.setPolicy(secondId, 'block', snapshot.revision),
|
|
(error) => error.code === 'REQUEST_INVALID',
|
|
);
|
|
});
|
|
|
|
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.equal(migrated.policy.schemaVersion, 1);
|
|
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
|
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
|
});
|
|
|
|
test('device inventory backs up and normalizes a malformed additive policy checkpoint', (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-migration-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, 'devices.json');
|
|
fs.writeFileSync(filePath, JSON.stringify({
|
|
schemaVersion: 2,
|
|
revision: 3,
|
|
devices: [],
|
|
traffic: { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [] },
|
|
policy: {
|
|
schemaVersion: 1,
|
|
defaultMode: 'vpn',
|
|
dataplaneEpoch: 42,
|
|
byMac: {
|
|
broken: { desired: 'direct', applied: 'vpn', status: 'failed' },
|
|
},
|
|
},
|
|
}));
|
|
const store = createJsonStore({
|
|
filePath,
|
|
defaultValue: {},
|
|
migrate: migrateDeviceInventoryState,
|
|
backupWhen: () => true,
|
|
});
|
|
|
|
const migrated = store.read();
|
|
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
|
assert.equal(migrated.policy.schemaVersion, 1);
|
|
assert.equal(migrated.policy.dataplaneEpoch, null);
|
|
assert.deepEqual(migrated.policy.byMac, {});
|
|
assert.match(migrated.policy.lastError, /device policy checkpoint/);
|
|
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
|
|
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]),
|
|
);
|
|
assert.equal(migrated.traffic.global.gateway.uploadBytes, '1300');
|
|
assert.equal(migrated.traffic.global.gateway.downloadBytes, '1500');
|
|
assert.deepEqual(
|
|
new Set(migrated.traffic.global.gateway.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.equal(snapshot.traffic.totalBytes, '2800');
|
|
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');
|
|
assert.equal(snapshot.traffic.totalBytes, '2945');
|
|
});
|
|
|
|
test('malformed proxy totals are backed up and an expired recovery marker is cleared', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-proxy-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const filePath = path.join(directory, 'devices.json');
|
|
const mac = '00:11:22:33:44:55';
|
|
fs.writeFileSync(filePath, JSON.stringify({
|
|
schemaVersion: 2,
|
|
revision: 1,
|
|
devices: [{
|
|
id: 'dev_0123456789abcdef',
|
|
alias: '',
|
|
pinned: false,
|
|
mac,
|
|
ip: '192.168.50.7',
|
|
interface: 'eth0',
|
|
firstSeenAt: '2026-06-01T12:00:00.000Z',
|
|
lastSeenAt: '2026-06-01T12:00:00.000Z',
|
|
source: 'neighbor',
|
|
confidence: 'high',
|
|
}],
|
|
traffic: {
|
|
baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [],
|
|
proxy: {
|
|
schemaVersion: 1,
|
|
baselinesByMac: { [mac]: { epoch: 'epoch-a', uploadBytes: '1', downloadBytes: '2' } },
|
|
totalsByMac: { [mac]: { uploadBytes: 'broken', downloadBytes: '4' } },
|
|
rebaselineMacs: [],
|
|
},
|
|
},
|
|
}));
|
|
const store = createJsonStore({
|
|
filePath,
|
|
defaultValue: {},
|
|
migrate: migrateDeviceInventoryState,
|
|
backupWhen: () => true,
|
|
});
|
|
assert.match(store.read().traffic.proxy.lastError, /proxy traffic checkpoint/);
|
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
|
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
now: () => new Date('2026-08-07T12:00:00.000Z'),
|
|
observe: () => ({ observedAt: '2026-08-07T12:00:00.000Z', observations: [], error: null }),
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a', generation: 'rules-a', observedAt: '2026-08-07T12:00:00.000Z',
|
|
source: { error: null }, devices: [],
|
|
}),
|
|
});
|
|
const snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices.length, 0);
|
|
assert.deepEqual(store.read().traffic.proxy.rebaselineMacs, []);
|
|
assert.equal(snapshot.source.traffic.proxy.error, null);
|
|
});
|
|
|
|
test('an old dataplane without domain traffic keeps inventory refresh and existing metrics available', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-domain-compat-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({ observedAt: '2026-08-08T10:00:00.000Z', observations: [], error: null }),
|
|
observeTraffic: () => ({
|
|
epoch: 'epoch-a',
|
|
generation: 'rules-a',
|
|
observedAt: '2026-08-08T10:00:00.000Z',
|
|
source: { error: null },
|
|
direct: { uploadBytes: '0', downloadBytes: '0' },
|
|
devices: [],
|
|
}),
|
|
observeDomainTraffic: () => { throw new Error('Dataplane HTTP 404'); },
|
|
});
|
|
|
|
const snapshot = await service.refresh();
|
|
assert.equal(snapshot.devices.length, 0);
|
|
assert.equal(service.metricsSnapshot().domainTraffic.source.error, 'Dataplane HTTP 404');
|
|
assert.deepEqual(service.metricsSnapshot().directTraffic, {
|
|
epoch: 'epoch-a',
|
|
observedAt: '2026-08-08T10:00:00.000Z',
|
|
source: { error: null },
|
|
uploadBytes: '0',
|
|
downloadBytes: '0',
|
|
series: [],
|
|
});
|
|
assert.equal(service.metricsSnapshot().traffic.totalBytes, '0');
|
|
});
|
|
|
|
test('direct IPv4 counters stay metrics-only and keep the last good transport snapshot', async (t) => {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-direct-metrics-'));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
|
|
const observedAt = '2026-08-12T10:00:00.000Z';
|
|
const mac = '00:11:22:33:44:55';
|
|
let fail = false;
|
|
let includeDirect = true;
|
|
const service = createDeviceInventoryService({
|
|
store,
|
|
observe: () => ({
|
|
observedAt,
|
|
error: null,
|
|
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
|
}),
|
|
observeTraffic: () => {
|
|
if (fail) throw new Error('Dataplane direct counters unavailable');
|
|
return {
|
|
epoch: 'epoch-a',
|
|
generation: 'rules-a',
|
|
observedAt,
|
|
source: { error: null },
|
|
direct: includeDirect ? { uploadBytes: '56', downloadBytes: '78' } : undefined,
|
|
devices: [{
|
|
mac,
|
|
uploadBytes: '100',
|
|
downloadBytes: '200',
|
|
...(includeDirect ? { directUploadBytes: '12', directDownloadBytes: '34' } : {}),
|
|
}],
|
|
};
|
|
},
|
|
});
|
|
|
|
await service.refresh();
|
|
assert.deepEqual(service.metricsSnapshot().directTraffic.series, [{
|
|
deviceId: deviceId(mac),
|
|
uploadBytes: '12',
|
|
downloadBytes: '34',
|
|
}]);
|
|
assert.equal(service.metricsSnapshot().directTraffic.uploadBytes, '56');
|
|
assert.equal(service.metricsSnapshot().directTraffic.downloadBytes, '78');
|
|
assert.equal(Object.hasOwn(service.snapshot().devices[0], 'directUploadBytes'), false);
|
|
assert.doesNotMatch(JSON.stringify(store.read()), /directUploadBytes|directDownloadBytes/);
|
|
|
|
fail = true;
|
|
await service.refresh();
|
|
assert.deepEqual(service.metricsSnapshot().directTraffic.series, [{
|
|
deviceId: deviceId(mac),
|
|
uploadBytes: '12',
|
|
downloadBytes: '34',
|
|
}]);
|
|
assert.equal(service.metricsSnapshot().directTraffic.source.error, 'Dataplane direct counters unavailable');
|
|
|
|
fail = false;
|
|
includeDirect = false;
|
|
await service.refresh();
|
|
assert.deepEqual(service.metricsSnapshot().directTraffic.series, []);
|
|
assert.equal(service.metricsSnapshot().directTraffic.source.error, null);
|
|
});
|