Files
harbor-net/test/server/device-routes.test.js
T
dokril 116686a138
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 14s
Implement Harbor gateway device ecosystem support
2026-08-31 02:06:22 +03:00

268 lines
9.5 KiB
JavaScript

import assert from 'node:assert/strict';
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 {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(payload) {
this.payload = JSON.parse(payload);
},
};
}
function createHarness({ inventory = {}, body = {} } = {}) {
const calls = [];
let bodyReads = 0;
const deviceInventory = inventory === null ? null : {
snapshot: () => {
calls.push(['snapshot']);
return inventory.snapshot ?? { revision: 1, devices: [] };
},
refresh: async () => {
calls.push(['refresh']);
return inventory.refresh ?? { revision: 2, devices: [] };
},
update: (...args) => {
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 };
},
setPolicy: async (...args) => {
calls.push(['setPolicy', ...args]);
return inventory.setPolicy ?? { revision: 5 };
},
};
const route = createDeviceInventoryRoute({
deviceInventory,
readBody: async () => {
bodyReads += 1;
return body;
},
});
return { route, calls, bodyReads: () => bodyReads };
}
test('device route forwards list and refresh query paths as raw JSON responses', async () => {
const harness = createHarness({
inventory: {
snapshot: { revision: 11, devices: [{ id: deviceId }] },
refresh: { revision: 12, devices: [] },
},
});
const listResponse = response();
assert.equal(await harness.route.handle({
method: 'GET',
url: '/api/devices?source=ui',
}, listResponse), true);
assert.equal(listResponse.status, 200);
assert.equal(listResponse.headers['content-type'], 'application/json; charset=utf-8');
assert.deepEqual(listResponse.payload, { revision: 11, devices: [{ id: deviceId }] });
const refreshResponse = response();
assert.equal(await harness.route.handle({
method: 'POST',
url: '/api/devices/refresh?source=ui',
}, refreshResponse), true);
assert.deepEqual(refreshResponse.payload, { revision: 12, devices: [] });
assert.deepEqual(harness.calls, [['snapshot'], ['refresh']]);
});
test('device route forwards metadata patch and policy arguments without coercion', async () => {
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({
method: 'PUT',
url: `/api/devices/${deviceId}?source=ui`,
}, metadataResponse), true);
assert.deepEqual(metadata.calls, [[
'update',
deviceId,
{ alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 },
7,
]]);
assert.deepEqual(metadataResponse.payload, { revision: 3 });
const policy = createHarness({ body: { mode: 42, expectedRevision: '8', ignored: true } });
const policyResponse = response();
assert.equal(await policy.route.handle({
method: 'PUT',
url: `/api/devices/${deviceId}/policy?source=ui`,
}, policyResponse), true);
assert.deepEqual(policy.calls, [['setPolicy', deviceId, 42, '8']]);
assert.deepEqual(policyResponse.payload, { revision: 5 });
const reset = createHarness({ body: { expectedRevision: '9', ignored: true } });
const resetResponse = response();
assert.equal(await reset.route.handle({
method: 'DELETE',
url: '/api/devices/traffic?source=ui',
}, resetResponse), true);
assert.deepEqual(reset.calls, [['resetTraffic', '9']]);
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'],
['GET', '/api/devices/refresh'],
['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(
harness.route.handle({ method, url }, response()),
(error) => error.code === 'ENDPOINT_NOT_FOUND',
);
assert.deepEqual(harness.calls, []);
assert.equal(harness.bodyReads(), 0);
}
for (const url of [
'/api/other',
'/api/devices/dev_0123456789ABCDEF',
'/api/devices/dev_short',
]) {
const harness = createHarness();
assert.equal(await harness.route.handle({ method: 'PUT', url }, response()), false);
assert.deepEqual(harness.calls, []);
assert.equal(harness.bodyReads(), 0);
}
for (const [method, url] of [
['GET', '/api/devices'],
['POST', '/api/devices/refresh'],
['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(
client.route.handle({ method, url }, response()),
(error) => error.code === 'ENDPOINT_NOT_FOUND',
);
assert.equal(client.bodyReads(), 0);
}
});
test('device route propagates synchronous and asynchronous service errors unchanged', async () => {
const syncError = new Error('snapshot failed');
const syncRoute = createDeviceInventoryRoute({
deviceInventory: {
snapshot: () => { throw syncError; },
refresh: async () => ({}),
update: () => ({}),
createTag: () => ({}),
renameTag: () => ({}),
deleteTag: () => ({}),
resetTraffic: async () => ({}),
setPolicy: async () => ({}),
},
readBody: async () => ({}),
});
await assert.rejects(
syncRoute.handle({ method: 'GET', url: '/api/devices' }, response()),
(error) => error === syncError,
);
const asyncError = new Error('refresh failed');
const asyncRoute = createDeviceInventoryRoute({
deviceInventory: {
snapshot: () => ({}),
refresh: async () => { throw asyncError; },
update: () => ({}),
createTag: () => ({}),
renameTag: () => ({}),
deleteTag: () => ({}),
resetTraffic: async () => ({}),
setPolicy: async () => ({}),
},
readBody: async () => ({}),
});
await assert.rejects(
asyncRoute.handle({ method: 'POST', url: '/api/devices/refresh' }, response()),
(error) => error === asyncError,
);
});
test('device route is the only HTTP owner while lifecycle stays in composition', () => {
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
const route = readFileSync(
new URL('../../src/server/http/routes/deviceInventoryRoute.ts', import.meta.url),
'utf8',
);
assert.match(index, /createDeviceInventoryRoute\(\{/);
assert.match(index, /deviceInventoryRoute\.handle\(req, res\)/);
assert.doesNotMatch(index, /\/api\/devices/);
assert.doesNotMatch(index, /deviceInventory\.(?:snapshot|update|setPolicy)\(/);
assert.match(index, /deviceInventory\.reconcilePolicies\(\)/);
assert.match(index, /deviceInventory\.refresh\(\)/);
assert.match(route, /DEVICE_PATH/);
assert.match(route, /DEVICE_POLICY_PATH/);
assert.match(route, /DEVICE_TAG_PATH/);
});