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'); });