Files

106 lines
6.6 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync, spawn } = require('node:child_process');
const { root } = require('./helpers/runtime');
const mac = 'AA:BB:CC:11:22:33';
function fixture(t, command, prefix) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-single-ping-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const leases = path.join(directory, 'leases'); fs.writeFileSync(leases, '');
const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8')
.replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"')
.replaceAll('/tmp/luci-device-manager-single-ping', path.join(directory, 'single-ping'))
.replaceAll('/tmp/dhcp.leases', leases).replaceAll('/var/dhcp.leases', leases);
const wrapper = `
ip() { printf '%s\\n' "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; }
ping() { printf '%s\\n' "$@" >> "$AUDIT_PING_LOG"; sleep "$AUDIT_DELAY"; printf '%s\\n' "$AUDIT_OUTPUT"; return "$AUDIT_PING_EXIT"; }
ping6() { ping "$@"; }
` + source;
let sequence = 0;
function options(extra) {
const id = sequence++;
const log = path.join(directory, 'pings-' + id);
return { log, options: { encoding: 'utf8', timeout: 10000, env: Object.assign({}, process.env, {
AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), AUDIT_NODE: process.execPath,
AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'),
AUDIT_JSON_LOG: path.join(directory, 'json-' + id), AUDIT_PING_LOG: log,
AUDIT_NEIGH: '192.168.1.20 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE',
AUDIT_IP_EXIT: '0', AUDIT_PING_EXIT: '0', AUDIT_DELAY: '0', AUDIT_OUTPUT: '1 packet received, time=0.5 ms'
}, extra) } };
}
return {
directory, leases,
run(request = { mac }, extra = {}) {
const cfg = options(extra);
const result = spawnSync(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'],
Object.assign({}, cfg.options, { input: JSON.stringify(request) }));
assert.equal(result.status, 0, result.stderr);
return { reply: JSON.parse(result.stdout), args: fs.existsSync(cfg.log) ? fs.readFileSync(cfg.log, 'utf8').trim().split('\n') : [] };
},
start(extra = {}) {
const cfg = options(extra);
const child = spawn(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'], cfg.options);
child.stdin.end(JSON.stringify({ mac }));
let stdout = '', stderr = '';
child.stdout.on('data', data => { stdout += data; }); child.stderr.on('data', data => { stderr += data; });
const done = new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
});
return { done, log: cfg.log };
}
};
}
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0;
test(`${command}: single-device Ping resolves the MAC, ignores caller targets and reports reply versus timeout`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
const result = f.run({ mac, ip: '8.8.8.8', command: 'touch /tmp/unsafe' });
assert.equal(result.reply.reachable, true); assert.equal(result.reply.ip, '192.168.1.20');
assert.equal(result.args.at(-1), '192.168.1.20'); assert.ok(result.args.includes('br-lan'));
assert.deepEqual(result.args.slice(0, 6), ['-c', '1', '-w', '3', '-W', '2']);
const timeout = f.run({ mac }, { AUDIT_PING_EXIT: '1' });
assert.equal(timeout.reply.ok, true); assert.equal(timeout.reply.reachable, false);
const error = f.run({ mac }, { AUDIT_PING_EXIT: '2', AUDIT_OUTPUT: 'invalid option' });
assert.equal(error.reply.ok, false); assert.equal(error.reply.error, 'Ping could not be completed');
});
test(`${command}: invalid and unknown MACs never launch Ping`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
for (const value of ['', null, 'aa:bb:cc:11:22:33; touch /tmp/unsafe', 'AA:BB:CC:11:22:33\ninvalid', "AA:BB:CC:11:22:33'$(touch /tmp/unsafe)'", 'FF:FF:FF:FF:FF:FF', '00:00:00:00:00:00', '11:22:33:44:55:66']) {
const result = f.run({ mac: value });
assert.equal(result.reply.ok, false); assert.deepEqual(result.args, []);
}
assert.deepEqual(f.run({ mac }, { AUDIT_IP_EXIT: '127' }).args, []);
});
test(`${command}: DHCP fallback and IPv6-only link-local neighbors use a single numeric destination`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 192.168.1.50 device *\n');
const lease = f.run({ mac }, { AUDIT_NEIGH: '' });
assert.equal(lease.reply.ip, '192.168.1.50'); assert.equal(lease.reply.interface, '');
const reassigned = f.run({ mac }, { AUDIT_NEIGH: '192.168.1.50 dev br-lan lladdr aa:bb:cc:44:55:66 REACHABLE' });
assert.equal(reassigned.reply.ok, false); assert.deepEqual(reassigned.args, []);
fs.writeFileSync(f.leases, '');
const v6 = f.run({ mac }, { AUDIT_NEIGH: 'fe80::1234 dev br-lan lladdr aa:bb:cc:11:22:33 STALE' });
assert.equal(v6.reply.ip, 'fe80::1234'); assert.equal(v6.reply.interface, 'br-lan');
assert.equal(v6.args.at(-1), 'fe80::1234'); assert.ok(v6.args.includes('-I'));
fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 999.1.2.3 invalid *\n');
assert.equal(f.run({ mac }, { AUDIT_NEIGH: '' }).reply.ok, false);
});
test(`${command}: a global nonblocking lock prevents concurrent detail probes`, { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const first = f.start({ AUDIT_DELAY: '0.3' });
for (let attempt = 0; attempt < 100 && !fs.existsSync(first.log); attempt++) await new Promise(resolve => setTimeout(resolve, 10));
assert.ok(fs.existsSync(first.log));
const second = f.run();
assert.equal(second.reply.ok, false); assert.match(second.reply.error, /Another device/); assert.deepEqual(second.args, []);
await first.done;
assert.equal(f.run().reply.reachable, true, 'lock must be released after the probe');
});
}