diff --git a/backend/package.json b/backend/package.json index bf099be..c338c41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,9 +20,10 @@ "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", + "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts", - "test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync", + "test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts" }, "dependencies": { diff --git a/backend/src/services/ros-file-contents.test.ts b/backend/src/services/ros-file-contents.test.ts new file mode 100644 index 0000000..6376d26 --- /dev/null +++ b/backend/src/services/ros-file-contents.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import { extractRosContentsField } from "./mikrotik.js" + +/** + * Собирает ответ RouterOS на `/file/get` (.proplist=contents) в том виде, в каком его + * отдаёт устройство: single-byte строка, экранируются только кавычка и обратный слэш. + */ +function rosFileGetResponse(contents: Buffer): Buffer { + const escaped: number[] = [] + for (const byte of contents) { + if (byte === 0x22 || byte === 0x5c) escaped.push(0x5c, byte) + else escaped.push(byte) + } + return Buffer.concat([ + Buffer.from('[{".id":"*A","contents":"', "latin1"), + Buffer.from(escaped), + Buffer.from('"}]', "latin1"), + ]) +} + +{ + // ASCII-содержимое + const content = Buffer.from("hello p12", "latin1") + assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content) +} + +{ + // Экранированные кавычки и обратный слэш + const content = Buffer.from('a"b\\c"d', "latin1") + assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content) +} + +{ + // Пустой файл + assert.equal(extractRosContentsField(rosFileGetResponse(Buffer.alloc(0))).length, 0) +} + +{ + // Управляющие escape-последовательности + const raw = Buffer.from('{"contents":"a\\nb\\tc\\u0041"}', "latin1") + assert.deepEqual(extractRosContentsField(raw), Buffer.from("a\nb\tcA", "latin1")) +} + +{ + // contents не первый ключ в объекте + const raw = Buffer.from('{".id":"*B","name":"x.p12","contents":"DATA"}', "latin1") + assert.deepEqual(extractRosContentsField(raw), Buffer.from("DATA", "latin1")) +} + +{ + // Все 256 байт: single-byte кодировка не должна терять значения 0x80–0xFF и NUL + const content = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) + const decoded = extractRosContentsField(rosFileGetResponse(content)) + assert.equal(decoded.length, 256) + assert.deepEqual(decoded, content) +} + +{ + // Нет поля contents — понятная ошибка + assert.throws( + () => extractRosContentsField(Buffer.from('{".id":"*A"}', "latin1")), + /не содержит поле contents/, + ) +} + +console.log("ros-file-contents.test.ts: ok")