Implement Harbor gateway device ecosystem support
This commit is contained in:
@@ -1443,3 +1443,141 @@ test('direct IPv4 counters stay metrics-only and keep the last good transport sn
|
||||
assert.deepEqual(service.metricsSnapshot().directTraffic.series, []);
|
||||
assert.equal(service.metricsSnapshot().directTraffic.source.error, null);
|
||||
});
|
||||
|
||||
test('device tag catalog validates, orders and mutates assignments under one revision', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tags-'));
|
||||
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-31T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [{ ip: '192.168.50.10', mac, interface: 'br0', observedAt, active: true }],
|
||||
}),
|
||||
now: () => new Date(observedAt),
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
snapshot = service.createTag(' Home ', snapshot.revision);
|
||||
const home = snapshot.tags[0];
|
||||
assert.match(home.id, /^tag_[a-f0-9]{16}$/);
|
||||
assert.equal(home.name, 'Home');
|
||||
assert.throws(
|
||||
() => service.createTag('home', snapshot.revision),
|
||||
(error) => error.code === 'DEVICE_TAG_NAME_CONFLICT',
|
||||
);
|
||||
assert.throws(
|
||||
() => service.createTag('x'.repeat(25), snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
|
||||
snapshot = service.createTag('Work', snapshot.revision);
|
||||
const work = snapshot.tags[1];
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: [work.id, home.id] }, snapshot.revision);
|
||||
assert.deepEqual(snapshot.devices[0].tagIds, [home.id, work.id]);
|
||||
assert.deepEqual(store.read().tags.byMac[mac], [home.id, work.id]);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision - 1),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
assert.equal(
|
||||
service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision).revision,
|
||||
snapshot.revision,
|
||||
);
|
||||
|
||||
snapshot = service.renameTag(home.id, 'HOME', snapshot.revision);
|
||||
const renamedRevision = snapshot.revision;
|
||||
assert.equal(snapshot.tags[0].name, 'HOME');
|
||||
assert.throws(
|
||||
() => service.renameTag(home.id, 'HOME', renamedRevision - 1),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
assert.equal(service.renameTag(home.id, 'HOME', renamedRevision).revision, renamedRevision);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: ['tag_ffffffffffffffff'] }, renamedRevision),
|
||||
(error) => error.code === 'DEVICE_TAG_NOT_FOUND',
|
||||
);
|
||||
|
||||
while (snapshot.tags.length < 9) snapshot = service.createTag(`Tag ${snapshot.tags.length}`, snapshot.revision);
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 8).map(({ id }) => id) }, snapshot.revision);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 9).map(({ id }) => id) }, snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
|
||||
snapshot = service.deleteTag(home.id, snapshot.revision);
|
||||
assert.equal(snapshot.tags.some(({ id }) => id === home.id), false);
|
||||
assert.equal(snapshot.devices[0].tagIds.includes(home.id), false);
|
||||
while (snapshot.tags.length < 32) snapshot = service.createTag(`More ${snapshot.tags.length}`, snapshot.revision);
|
||||
assert.throws(
|
||||
() => service.createTag('Overflow', snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('tag migration recovers safe values and retention removes assignment but keeps catalog', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tag-retention-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
let current = new Date('2026-07-01T12:00:00.000Z');
|
||||
const mac = '00:11:22:33:44:66';
|
||||
let present = true;
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt: current.toISOString(),
|
||||
error: null,
|
||||
observations: present
|
||||
? [{ ip: '192.168.50.12', mac, interface: 'br0', observedAt: current.toISOString(), active: true }]
|
||||
: [],
|
||||
}),
|
||||
now: () => current,
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
snapshot = service.createTag('Дом', snapshot.revision);
|
||||
const tag = snapshot.tags[0];
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: [tag.id] }, snapshot.revision);
|
||||
|
||||
const recovered = migrateDeviceInventoryState({
|
||||
...store.read(),
|
||||
tags: {
|
||||
schemaVersion: 1,
|
||||
items: [tag, { ...tag, id: 'tag_ffffffffffffffff' }, { id: 'bad', name: 'Bad' }],
|
||||
byMac: { [mac.toUpperCase()]: [tag.id, tag.id, 'tag_ffffffffffffffff'] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered.tags, {
|
||||
schemaVersion: 1,
|
||||
items: [tag],
|
||||
byMac: { [mac]: [tag.id] },
|
||||
});
|
||||
assert.throws(
|
||||
() => migrateDeviceInventoryState({ ...store.read(), tags: { schemaVersion: 2 } }),
|
||||
/Unsupported device tags schemaVersion/,
|
||||
);
|
||||
|
||||
present = false;
|
||||
current = new Date('2026-08-02T12:00:00.000Z');
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.length, 0);
|
||||
assert.deepEqual(snapshot.tags, [tag]);
|
||||
assert.deepEqual(store.read().tags.byMac, {});
|
||||
|
||||
present = true;
|
||||
current = new Date('2026-08-03T12:00:00.000Z');
|
||||
snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices[0].tagIds, []);
|
||||
assert.deepEqual(snapshot.tags, [tag]);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js';
|
||||
import { errorDefinition } from '../../dist/shared/errors.js';
|
||||
|
||||
const deviceId = 'dev_0123456789abcdef';
|
||||
const tagId = 'tag_0123456789abcdef';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
@@ -34,6 +36,18 @@ function createHarness({ inventory = {}, body = {} } = {}) {
|
||||
calls.push(['update', ...args]);
|
||||
return inventory.update ?? { revision: 3 };
|
||||
},
|
||||
createTag: (...args) => {
|
||||
calls.push(['createTag', ...args]);
|
||||
return inventory.createTag ?? { revision: 6 };
|
||||
},
|
||||
renameTag: (...args) => {
|
||||
calls.push(['renameTag', ...args]);
|
||||
return inventory.renameTag ?? { revision: 7 };
|
||||
},
|
||||
deleteTag: (...args) => {
|
||||
calls.push(['deleteTag', ...args]);
|
||||
return inventory.deleteTag ?? { revision: 8 };
|
||||
},
|
||||
resetTraffic: async (...args) => {
|
||||
calls.push(['resetTraffic', ...args]);
|
||||
return inventory.resetTraffic ?? { revision: 4 };
|
||||
@@ -79,7 +93,7 @@ test('device route forwards list and refresh query paths as raw JSON responses',
|
||||
});
|
||||
|
||||
test('device route forwards metadata patch and policy arguments without coercion', async () => {
|
||||
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, extra: 0 };
|
||||
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 };
|
||||
const metadata = createHarness({ body: patch });
|
||||
const metadataResponse = response();
|
||||
assert.equal(await metadata.route.handle({
|
||||
@@ -89,7 +103,7 @@ test('device route forwards metadata patch and policy arguments without coercion
|
||||
assert.deepEqual(metadata.calls, [[
|
||||
'update',
|
||||
deviceId,
|
||||
{ alias: 'Desk', pinned: false, deprioritized: true, extra: 0 },
|
||||
{ alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 },
|
||||
7,
|
||||
]]);
|
||||
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
||||
@@ -113,6 +127,39 @@ test('device route forwards metadata patch and policy arguments without coercion
|
||||
assert.deepEqual(resetResponse.payload, { revision: 4 });
|
||||
});
|
||||
|
||||
test('device tag routes forward catalog mutations and return complete snapshots', async () => {
|
||||
const create = createHarness({ body: { name: 'Умный дом', expectedRevision: 10 } });
|
||||
const createResponse = response();
|
||||
assert.equal(await create.route.handle({ method: 'POST', url: '/api/device-tags' }, createResponse), true);
|
||||
assert.deepEqual(create.calls, [['createTag', 'Умный дом', 10]]);
|
||||
assert.deepEqual(createResponse.payload, { revision: 6 });
|
||||
|
||||
const rename = createHarness({ body: { name: 'Дом', expectedRevision: 11 } });
|
||||
const renameResponse = response();
|
||||
assert.equal(await rename.route.handle({ method: 'PUT', url: `/api/device-tags/${tagId}` }, renameResponse), true);
|
||||
assert.deepEqual(rename.calls, [['renameTag', tagId, 'Дом', 11]]);
|
||||
assert.deepEqual(renameResponse.payload, { revision: 7 });
|
||||
|
||||
const remove = createHarness({ body: { expectedRevision: 12 } });
|
||||
const deleteResponse = response();
|
||||
assert.equal(await remove.route.handle({ method: 'DELETE', url: `/api/device-tags/${tagId}` }, deleteResponse), true);
|
||||
assert.deepEqual(remove.calls, [['deleteTag', tagId, 12]]);
|
||||
assert.deepEqual(deleteResponse.payload, { revision: 8 });
|
||||
});
|
||||
|
||||
test('device tag errors expose stable HTTP contracts', () => {
|
||||
assert.deepEqual(errorDefinition('DEVICE_TAG_NOT_FOUND'), {
|
||||
status: 404,
|
||||
message: 'Тег больше недоступен.',
|
||||
retryable: false,
|
||||
});
|
||||
assert.deepEqual(errorDefinition('DEVICE_TAG_NAME_CONFLICT'), {
|
||||
status: 409,
|
||||
message: 'Тег с таким именем уже существует.',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/devices'],
|
||||
@@ -120,6 +167,8 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
||||
['GET', '/api/devices/traffic'],
|
||||
['GET', `/api/devices/${deviceId}`],
|
||||
['POST', `/api/devices/${deviceId}/policy`],
|
||||
['GET', '/api/device-tags'],
|
||||
['PATCH', `/api/device-tags/${tagId}`],
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
await assert.rejects(
|
||||
@@ -147,6 +196,9 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
||||
['DELETE', '/api/devices/traffic'],
|
||||
['PUT', `/api/devices/${deviceId}`],
|
||||
['PUT', `/api/devices/${deviceId}/policy`],
|
||||
['POST', '/api/device-tags'],
|
||||
['PUT', `/api/device-tags/${tagId}`],
|
||||
['DELETE', `/api/device-tags/${tagId}`],
|
||||
]) {
|
||||
const client = createHarness({ inventory: null });
|
||||
await assert.rejects(
|
||||
@@ -164,6 +216,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => { throw syncError; },
|
||||
refresh: async () => ({}),
|
||||
update: () => ({}),
|
||||
createTag: () => ({}),
|
||||
renameTag: () => ({}),
|
||||
deleteTag: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
@@ -180,6 +235,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => ({}),
|
||||
refresh: async () => { throw asyncError; },
|
||||
update: () => ({}),
|
||||
createTag: () => ({}),
|
||||
renameTag: () => ({}),
|
||||
deleteTag: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
@@ -205,4 +263,5 @@ test('device route is the only HTTP owner while lifecycle stays in composition',
|
||||
assert.match(index, /deviceInventory\.refresh\(\)/);
|
||||
assert.match(route, /DEVICE_PATH/);
|
||||
assert.match(route, /DEVICE_POLICY_PATH/);
|
||||
assert.match(route, /DEVICE_TAG_PATH/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user