mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
⛅ Sync 2026-10-11 13:16:00
This commit is contained in:
1 parent
705dadd362
commit
e6d1b4e2d7
105 files changed
+26511
-50
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# Test double for the standard jshn API. Records typed arguments, not plugin logic.
|
||||
json_init() { printf 'init\0\0\0' > "$AUDIT_JSON_LOG"; }
|
||||
json_add_boolean() { printf 'boolean\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; }
|
||||
json_add_string() { printf 'string\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; }
|
||||
json_add_array() { printf 'array\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; }
|
||||
json_add_object() { printf 'object\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; }
|
||||
json_close_array() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; }
|
||||
json_close_object() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; }
|
||||
json_dump() { "$AUDIT_NODE" "$AUDIT_JSON_ENCODER" "$AUDIT_JSON_LOG"; }
|
||||
json_load() {
|
||||
AUDIT_JSON_INPUT="$1"
|
||||
export AUDIT_JSON_INPUT
|
||||
"$AUDIT_NODE" -e 'JSON.parse(process.env.AUDIT_JSON_INPUT)' >/dev/null 2>&1
|
||||
}
|
||||
json_get_var() {
|
||||
# Match jshn assignments without losing embedded newlines or evaluating input.
|
||||
eval "$("$AUDIT_NODE" -e '
|
||||
const value=JSON.parse(process.env.AUDIT_JSON_INPUT)[process.argv[2]];
|
||||
const text=typeof value==="string" ? value : "";
|
||||
const quote=String.fromCharCode(39);
|
||||
process.stdout.write(process.argv[1]+"="+quote+text.split(quote).join(quote+"\\"+quote+quote)+quote);
|
||||
' "$1" "$2")"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const fields = fs.readFileSync(process.argv[2], 'utf8').split('\0');
|
||||
const root = {}, stack = [root];
|
||||
for (let i = 0; i + 2 < fields.length; i += 3) {
|
||||
const [type, key, raw] = fields.slice(i, i + 3);
|
||||
if (type === 'init') continue;
|
||||
if (type === 'close') { stack.pop(); continue; }
|
||||
const value = type === 'array' ? [] : type === 'object' ? {} : type === 'boolean' ? raw === '1' : raw;
|
||||
const current = stack[stack.length - 1];
|
||||
if (Array.isArray(current)) current.push(value);
|
||||
else current[key] = value;
|
||||
if (type === 'array' || type === 'object') stack.push(value);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(root) + '\n');
|
||||
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
String.prototype.format = function(...values) {
|
||||
let index = 0;
|
||||
return this.replace(/%[sd]/g, () => String(values[index++]));
|
||||
};
|
||||
|
||||
class Node extends EventTarget {
|
||||
constructor(tag, attrs = {}) {
|
||||
super();
|
||||
this.tag = tag;
|
||||
this.attrs = attrs;
|
||||
this.children = [];
|
||||
this.style = {};
|
||||
// LuCI sets every non-null attribute; even disabled="false" disables a button.
|
||||
this.disabled = attrs.disabled != null;
|
||||
this.selected = false;
|
||||
this._value = attrs.value;
|
||||
this.classes = new Set((attrs.class || '').split(/\s+/));
|
||||
this.classList = { add: name => this.classes.add(name), remove: name => this.classes.delete(name) };
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
if (typeof value === 'function') this.addEventListener(name, event => { this.operation = value(event); });
|
||||
}
|
||||
}
|
||||
appendChild(child) { this.children.push(child); return child; }
|
||||
get value() {
|
||||
if (this._value != null) return this._value;
|
||||
if (this.tag === 'select') {
|
||||
const option = this.children.find(child => child.selected) || this.children[0];
|
||||
return option ? option.attrs.value : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
set value(value) { this._value = value; }
|
||||
get textContent() { return this.children.map(child => child instanceof Node ? child.textContent : String(child)).join(''); }
|
||||
set textContent(value) { this.children = [String(value)]; }
|
||||
querySelectorAll(selector) {
|
||||
return this.children.filter(child => child instanceof Node).flatMap(child => [child, ...child.querySelectorAll('*')])
|
||||
.filter(child => selector === '*' || selector === child.tag ||
|
||||
(selector.startsWith('#') && child.attrs.id === selector.slice(1)) ||
|
||||
(selector.startsWith('.') && child.classes.has(selector.slice(1))));
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
focus() { this.focused = true; }
|
||||
getAttribute(name) { return this.attrs[name]; }
|
||||
click() {
|
||||
if (this.disabled) return Promise.resolve();
|
||||
this.dispatchEvent(new Event('click'));
|
||||
return Promise.resolve(this.operation);
|
||||
}
|
||||
}
|
||||
|
||||
function environment(extra = {}) {
|
||||
const htmlSinks = [], notifications = [];
|
||||
const E = (tag, attrs, children) => {
|
||||
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) { children = attrs; attrs = {}; }
|
||||
const node = new Node(tag, attrs);
|
||||
if (Array.isArray(children)) children.forEach(child => node.appendChild(child));
|
||||
else if (children instanceof Node) node.appendChild(children);
|
||||
else if (children != null) {
|
||||
// Match LuCI dom.append: scalar content uses innerHTML, array items use text nodes.
|
||||
htmlSinks.push(String(children));
|
||||
node.appendChild(String(children));
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const dom = { content: (node, children) => {
|
||||
node.children = [];
|
||||
if (Array.isArray(children)) children.forEach(child => node.appendChild(child));
|
||||
else if (children != null) node.appendChild(children);
|
||||
} };
|
||||
const ui = {
|
||||
modal: null,
|
||||
showModal(title, children) { this.modal = E('modal', { title }, children); },
|
||||
hideModal() { ui.modal = null; },
|
||||
addNotification(title, content, level) { notifications.push({ content, level }); }
|
||||
};
|
||||
return Object.assign({
|
||||
baseclass: { extend: value => value }, view: { extend: value => value }, E, dom, ui,
|
||||
rpc: { declare: () => () => Promise.resolve({ language: 'auto' }) },
|
||||
_: value => value, htmlSinks, notifications,
|
||||
L: { hasViewPermission: () => true, resource: value => '/resources/' + value },
|
||||
window: { localStorage: { getItem: () => null, setItem() {} } }
|
||||
}, extra);
|
||||
}
|
||||
|
||||
function loadModule(name, env, cache = new Map()) {
|
||||
if (cache.has(name)) return cache.get(name);
|
||||
const file = path.join(root, 'htdocs/luci-static/resources', name.replace(/\./g, '/') + '.js');
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const dependencies = Object.assign({}, env);
|
||||
for (const match of source.matchAll(/'require ([\w.-]+)(?: as (\w+))?';/g)) {
|
||||
const [, dependency, alias] = match;
|
||||
dependencies[alias || dependency] = env[dependency] || loadModule(dependency, env, cache);
|
||||
}
|
||||
const result = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies));
|
||||
cache.set(name, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function backend(initial = []) {
|
||||
const clone = value => JSON.parse(JSON.stringify(value));
|
||||
const state = { saved: clone(initial), staged: null, cached: null, reads: 0, commits: 0, reverts: 0, calls: [], declarations: [] };
|
||||
const uci = {
|
||||
unload() { state.cached = null; },
|
||||
load() {
|
||||
if (!state.cached) { state.reads++; state.cached = clone(state.staged || state.saved); }
|
||||
return Promise.resolve();
|
||||
},
|
||||
sections(config, type) { return state.cached.filter(section => section['.type'] === type); },
|
||||
get(config, id) { return state.cached.find(section => section['.name'] === id) || null; },
|
||||
add(config, type, id) { state.cached.push({ '.type': type, '.name': id }); return id; },
|
||||
set(config, id, key, value) { uci.get(config, id)[key] = value; },
|
||||
unset(config, id, key) { delete uci.get(config, id)[key]; },
|
||||
remove(config, id) { state.cached = state.cached.filter(section => section['.name'] !== id); },
|
||||
save() {
|
||||
state.staged = clone(state.cached);
|
||||
return state.failSave ? Promise.reject(new Error('save failed')) : Promise.resolve();
|
||||
}
|
||||
};
|
||||
const replies = {
|
||||
'luci-rpc.getHostHints': {},
|
||||
'luci-rpc.getDHCPLeases': { dhcp_leases: [], dhcp6_leases: [] },
|
||||
'luci-rpc.getWirelessDevices': {},
|
||||
'luci.device-manager.get_online_status': { ok: true, neighbors: [] },
|
||||
'luci.device-manager.scan_devices': { ok: true },
|
||||
'luci.device-manager.ping_device': { ok: true, ip: '192.168.1.20', interface: 'br-lan', reachable: true, output: '1 packets transmitted, 1 packets received' },
|
||||
'iwinfo.assoclist': { results: [] }
|
||||
};
|
||||
const rpc = { declare(options) {
|
||||
state.declarations.push(options);
|
||||
return (...args) => {
|
||||
const key = options.object + '.' + options.method;
|
||||
state.calls.push({ key, args });
|
||||
if (key === 'uci.commit') {
|
||||
if (state.failCommit) return options.reject ? Promise.reject(new Error('commit failed')) : Promise.resolve(6);
|
||||
state.saved = clone(state.staged || state.saved); state.staged = null; state.commits++;
|
||||
return Promise.resolve({});
|
||||
}
|
||||
if (key === 'uci.revert') {
|
||||
state.reverts++;
|
||||
if (state.failRevert) return Promise.reject(new Error('revert failed'));
|
||||
state.staged = null; return Promise.resolve({});
|
||||
}
|
||||
const result = typeof replies[key] === 'function' ? replies[key](...args) : replies[key];
|
||||
return result instanceof Error ? Promise.reject(result) : Promise.resolve(clone(result));
|
||||
};
|
||||
} };
|
||||
return { state, uci, rpc, replies, fs: { read: () => Promise.resolve('IP address HW type Flags HW address Mask Device\n') } };
|
||||
}
|
||||
|
||||
module.exports = { root, Node, environment, loadModule, backend };
|
||||
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { environment, loadModule, backend } = require('./helpers/runtime');
|
||||
const mac = 'AA:BB:CC:11:22:33';
|
||||
const attack = '<img src=x onerror="window.auditFlag=1">';
|
||||
|
||||
async function context() {
|
||||
const mocks = backend([
|
||||
{ '.type': 'group', '.name': 'home', name: 'Home' },
|
||||
{ '.type': 'device', '.name': 'saved', mac, name: 'Living room', remark: attack, group: 'home', type: 'tv' }
|
||||
]);
|
||||
mocks.replies['luci-rpc.getHostHints'] = { [mac]: {
|
||||
name: 'television', ipaddrs: ['192.168.1.20', '192.168.1.21'],
|
||||
ip6addrs: ['fd00::20', 'fe80::20']
|
||||
} };
|
||||
mocks.replies['luci.device-manager.get_online_status'] = { ok: true, neighbors: [
|
||||
{ mac, ip: '192.168.1.20', dev: 'br-lan', state: 'REACHABLE' },
|
||||
{ mac, ip: 'fd00::20', dev: 'br-lan', state: 'STALE' }
|
||||
] };
|
||||
const env = environment(mocks);
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
const node = page.render(await page.load());
|
||||
return Object.assign({ env, page, node, dev: page.devices[0] }, mocks);
|
||||
}
|
||||
|
||||
test('details retain all known addresses and evidence and open with exactly one independent Ping', async () => {
|
||||
const ctx = await context();
|
||||
const scans = ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length;
|
||||
assert.deepEqual(ctx.dev.ipv4Addresses, ['192.168.1.20', '192.168.1.21']);
|
||||
assert.deepEqual(ctx.dev.ipv6Addresses, ['fd00::20', 'fe80::20']);
|
||||
assert.deepEqual(ctx.dev.interfaces, ['br-lan']);
|
||||
await ctx.node.querySelector('.dm-action-detail').click();
|
||||
const modal = ctx.env.ui.modal;
|
||||
assert.equal(modal.attrs.title, 'Device details');
|
||||
for (const value of ['Living room', 'television', '192.168.1.20', '192.168.1.21', 'fd00::20', 'fe80::20', mac, 'Home', attack, 'br-lan', 'REACHABLE', 'STALE']) {
|
||||
assert.ok(modal.textContent.includes(value), value);
|
||||
}
|
||||
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied');
|
||||
const calls = ctx.state.calls.filter(call => call.key === 'luci.device-manager.ping_device');
|
||||
assert.equal(calls.length, 1); assert.deepEqual(calls[0].args, [mac]);
|
||||
assert.equal(ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length, scans);
|
||||
assert.equal(ctx.state.commits, 0); assert.deepEqual(ctx.env.htmlSinks, []);
|
||||
});
|
||||
|
||||
test('unanswered Ping is distinct from offline and retry does not overlap an in-flight request', async () => {
|
||||
const ctx = await context();
|
||||
let release;
|
||||
ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; });
|
||||
const pending = ctx.page.showDetailModal(ctx.dev);
|
||||
await Promise.resolve();
|
||||
const modal = ctx.env.ui.modal;
|
||||
const button = modal.querySelectorAll('button').find(node => node.textContent === 'Ping again');
|
||||
assert.equal(button.disabled, true);
|
||||
await button.click();
|
||||
release({ reachable: false, ip: '192.168.1.20', output: attack });
|
||||
await pending;
|
||||
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'No Ping reply');
|
||||
assert.ok(modal.textContent.includes('does not confirm it is offline'));
|
||||
assert.equal(modal.querySelector('pre').textContent, attack);
|
||||
assert.equal(ctx.dev.status, 'online'); assert.equal(button.disabled, false);
|
||||
assert.deepEqual(ctx.env.htmlSinks, []);
|
||||
ctx.page.handlePingDevice = () => Promise.resolve({ reachable: true, ip: '192.168.1.20', output: 'reply' });
|
||||
await button.click();
|
||||
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied');
|
||||
});
|
||||
|
||||
test('missing addresses and RPC failures remain visible and restore the retry control', async () => {
|
||||
const ctx = await context();
|
||||
for (const response of [{ ok: false, error: 'No known IP address for this device' }, new Error('RPC unavailable'), { ok: true }]) {
|
||||
ctx.replies['luci.device-manager.ping_device'] = response;
|
||||
await ctx.page.showDetailModal(ctx.dev);
|
||||
const modal = ctx.env.ui.modal;
|
||||
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Probe unavailable');
|
||||
assert.equal(modal.querySelectorAll('button').find(node => node.textContent === 'Ping again').disabled, false);
|
||||
assert.equal(ctx.dev.status, 'online');
|
||||
}
|
||||
});
|
||||
|
||||
test('late probe completion updates only its own content and never replaces a newer dialog', async () => {
|
||||
const ctx = await context();
|
||||
let release;
|
||||
ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; });
|
||||
const pending = ctx.page.showDetailModal(ctx.dev);
|
||||
await Promise.resolve();
|
||||
ctx.page.showEditModal(ctx.dev);
|
||||
const editModal = ctx.env.ui.modal;
|
||||
release({ reachable: true, ip: '192.168.1.20', output: 'reply' });
|
||||
await pending;
|
||||
assert.equal(ctx.env.ui.modal, editModal); assert.equal(editModal.attrs.title, 'Edit device');
|
||||
});
|
||||
|
||||
test('read-only sessions can view details and probe without getting editing or clearing controls', async () => {
|
||||
const ctx = await context();
|
||||
ctx.env.L.hasViewPermission = () => false;
|
||||
ctx.page.readonly = true;
|
||||
ctx.page.updateView();
|
||||
assert.equal(ctx.node.querySelector('.dm-action-edit'), null);
|
||||
await ctx.node.querySelector('.dm-action-detail').click();
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Device details');
|
||||
assert.ok(!ctx.env.ui.modal.textContent.includes('Clear record'));
|
||||
assert.equal(ctx.state.commits, 0);
|
||||
});
|
||||
|
||||
test('clear record removes metadata after confirmation while discovered devices and groups remain', async () => {
|
||||
const ctx = await context();
|
||||
ctx.page.showEditModal(ctx.dev);
|
||||
const clear = ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Clear record');
|
||||
await clear.click();
|
||||
assert.equal(ctx.state.saved.length, 2, 'opening confirmation must not clear anything');
|
||||
await ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Confirm clear').click();
|
||||
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0]['.type'], 'group');
|
||||
const dev = ctx.page.devices.find(device => device.mac === mac);
|
||||
assert.equal(dev.isSaved, false); assert.equal(dev.customName, ''); assert.equal(dev.remark, '');
|
||||
assert.equal(dev.group, 'ungrouped'); assert.equal(dev.hostname, 'television'); assert.equal(dev.customType, undefined);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { environment, loadModule, backend, root } = require('./helpers/runtime');
|
||||
|
||||
const EXPECTED_ICONS = [
|
||||
'computer.svg', 'laptop.svg', 'phone.svg', 'tablet.svg',
|
||||
'tv.svg', 'tvbox.svg', 'nas.svg', 'printer.svg',
|
||||
'camera.svg', 'speaker.svg', 'game.svg', 'router.svg',
|
||||
'switch.svg', 'ap.svg', 'server.svg', 'plug.svg',
|
||||
'light.svg', 'sensor.svg', 'home.svg', 'watch.svg',
|
||||
'car.svg', 'vr.svg', 'network.svg',
|
||||
'airconditioner.svg', 'washer.svg', 'fridge.svg',
|
||||
'waterpurifier.svg', 'airpurifier.svg',
|
||||
'unknown.svg'
|
||||
];
|
||||
|
||||
function pageContext(initial = []) {
|
||||
const mocks = backend(initial);
|
||||
const env = environment(mocks);
|
||||
const cache = new Map();
|
||||
const model = loadModule('device-manager.model', env, cache);
|
||||
const service = loadModule('device-manager.service', env, cache);
|
||||
const page = loadModule('view.device-manager.devices', env, cache);
|
||||
return Object.assign({ env, page, model, service }, mocks);
|
||||
}
|
||||
|
||||
test('all 29 distributed icons have one source and pinned attribution', () => {
|
||||
const directory = path.join(root, 'htdocs/luci-static/resources/device-manager/device-icons');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'sources.json')));
|
||||
assert.match(manifest.revision, /^[a-f0-9]{40}$/);
|
||||
for (const icon of EXPECTED_ICONS) {
|
||||
const content = fs.readFileSync(path.join(directory, icon), 'utf8');
|
||||
assert.match(content, /<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
||||
assert.match(content, /<\/svg>/);
|
||||
assert.ok(manifest.icons[icon].source.includes(manifest.revision));
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(directory, 'LICENSE')));
|
||||
assert.ok(fs.existsSync(path.join(directory, 'NOTICE')));
|
||||
});
|
||||
|
||||
test('vendor hints remain conservative and randomized MACs do not imply a vendor', () => {
|
||||
const env = environment();
|
||||
const model = loadModule('device-manager.model', env);
|
||||
|
||||
const cases = [
|
||||
{ mac: '00:11:32:11:22:33', expected: 'nas' },
|
||||
{ mac: '4C:FC:AA:11:22:33', expected: 'car' },
|
||||
{ mac: '2C:26:17:11:22:33', expected: 'vr' },
|
||||
{ mac: '00:1B:78:11:22:33', expected: 'unknown' },
|
||||
{ mac: '10:12:FB:11:22:33', expected: 'camera' },
|
||||
{ mac: '00:09:BF:11:22:33', expected: 'game' },
|
||||
{ mac: '00:0E:58:11:22:33', expected: 'speaker' },
|
||||
{ mac: 'F4:F5:E8:11:22:33', expected: 'unknown' },
|
||||
{ mac: '00:1A:9A:11:22:33', expected: 'unknown' },
|
||||
{ mac: '18:FE:34:11:22:33', expected: 'home' },
|
||||
{ mac: '00:17:88:11:22:33', expected: 'light' },
|
||||
{ mac: '54:EF:44:11:22:33', expected: 'home' },
|
||||
{ mac: '10:2C:6B:11:22:33', expected: 'unknown' },
|
||||
{ mac: '52:54:00:11:22:33', expected: 'unknown' },
|
||||
{ mac: '00:15:6D:11:22:33', expected: 'network' },
|
||||
{ mac: '00:0F:E2:11:22:33', expected: 'network' },
|
||||
{ mac: '00:0C:42:11:22:33', expected: 'router' },
|
||||
{ mac: 'B8:27:EB:11:22:33', expected: 'computer' },
|
||||
{ mac: 'F0:18:98:11:22:33', expected: 'unknown' },
|
||||
{ mac: '12:34:56:78:9A:BC', expected: 'unknown' }
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
const detected = model.detectDeviceType(c.mac, '', '', '');
|
||||
assert.equal(detected, c.expected, `MAC ${c.mac} should be ${c.expected}, got ${detected}`);
|
||||
const info = model.getTypeInfo(detected);
|
||||
assert.equal(info.icon, detected + '.svg');
|
||||
}
|
||||
});
|
||||
|
||||
test('detectDeviceType refines classification using hostname or custom name clues', () => {
|
||||
const env = environment();
|
||||
const model = loadModule('device-manager.model', env);
|
||||
|
||||
const appleMac = 'F0:18:98:11:22:33';
|
||||
assert.equal(model.detectDeviceType(appleMac, 'My-iPhone', '', ''), 'phone');
|
||||
assert.equal(model.detectDeviceType(appleMac, 'MacBook-Pro', '', ''), 'laptop');
|
||||
assert.equal(model.detectDeviceType(appleMac, 'iPad-Mini', '', ''), 'tablet');
|
||||
assert.equal(model.detectDeviceType(appleMac, 'Apple-Watch-Ultra', '', ''), 'watch');
|
||||
assert.equal(model.detectDeviceType(appleMac, 'Apple-TV-4K', '', ''), 'tvbox');
|
||||
assert.equal(model.detectDeviceType(appleMac, 'HomePod-LivingRoom', '', ''), 'speaker');
|
||||
|
||||
const genericMac = '12:34:56:78:9A:BC';
|
||||
assert.equal(model.detectDeviceType(genericMac, 'DiskStation', '', ''), 'nas');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Tesla-Model-Y', '', ''), 'car');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Oculus-Quest-3', '', ''), 'vr');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'HP-LaserJet', '', ''), 'printer');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Hikvision-IPC', '', ''), 'camera');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'PlayStation-5', '', ''), 'game');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'USW-Lite-16-PoE', '', ''), 'switch');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'UniFi-U6-Pro', '', ''), 'ap');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Gree-AirConditioner', '', ''), 'airconditioner');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'LivingRoom-空调', '', ''), 'airconditioner');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'LG-Washer', '', ''), 'washer');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Haier-洗衣机', '', ''), 'washer');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Samsung-Fridge', '', ''), 'fridge');
|
||||
assert.equal(model.detectDeviceType(genericMac, '厨房冰箱', '', ''), 'fridge');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Xiaomi-WaterPurifier', '', ''), 'waterpurifier');
|
||||
assert.equal(model.detectDeviceType(genericMac, '小米净水器', '', ''), 'waterpurifier');
|
||||
assert.equal(model.detectDeviceType(genericMac, 'Mi-AirPurifier-Pro', '', ''), 'airpurifier');
|
||||
assert.equal(model.detectDeviceType(genericMac, '卧室空气净化器', '', ''), 'airpurifier');
|
||||
});
|
||||
|
||||
test('device table renders the device icon after the row number', async () => {
|
||||
const ctx = pageContext();
|
||||
const nasMac = '00:11:32:11:22:33';
|
||||
ctx.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [{ mac: nasMac, state: 'REACHABLE' }]
|
||||
};
|
||||
ctx.replies['luci-rpc.getHostHints'] = {
|
||||
[nasMac]: { name: 'Synology-NAS', ipaddrs: ['192.168.1.50'] }
|
||||
};
|
||||
|
||||
const data = await ctx.page.load();
|
||||
const viewNode = ctx.page.render(data);
|
||||
|
||||
// Check table thead has type column
|
||||
const table = viewNode.querySelector('#device_manager_table');
|
||||
const thList = table.querySelectorAll('th');
|
||||
assert.equal(thList[1].classes.has('dm-type-th'), true);
|
||||
|
||||
// Check tbody row has the icon after the row number
|
||||
const tbody = viewNode.querySelector('#device_manager_tbody');
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
assert.equal(rows.length, 1);
|
||||
|
||||
const row = rows[0];
|
||||
const tdList = row.querySelectorAll('td');
|
||||
assert.equal(tdList[1].classes.has('dm-type-cell'), true);
|
||||
|
||||
const iconImg = tdList[1].querySelector('img');
|
||||
assert.ok(iconImg);
|
||||
assert.equal(iconImg.classes.has('dm-device-icon'), true);
|
||||
assert.match(iconImg.attrs.src, /device-icons\/nas\.svg/);
|
||||
});
|
||||
|
||||
test('custom device type override can be saved and displayed', async () => {
|
||||
const customMac = '00:11:32:55:66:77';
|
||||
const initial = [
|
||||
{ '.type': 'device', '.name': 'dev_001132556677', mac: customMac, name: 'Backup Server', type: 'server' }
|
||||
];
|
||||
const ctx = pageContext(initial);
|
||||
ctx.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [{ mac: customMac, state: 'REACHABLE' }]
|
||||
};
|
||||
const data = await ctx.page.load();
|
||||
const viewNode = ctx.page.render(data);
|
||||
|
||||
const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
|
||||
const iconImg = row.querySelectorAll('td')[1].querySelector('img');
|
||||
assert.match(iconImg.attrs.src, /device-icons\/server\.svg/);
|
||||
|
||||
// Test saving a new type via service
|
||||
await ctx.service.saveDevice(customMac, 'Backup Server', 'Rack 1', 'ungrouped', 'nas');
|
||||
assert.equal(ctx.state.saved[0].type, 'nas');
|
||||
});
|
||||
|
||||
test('ambiguous short tokens and general vendors do not override meaningful device names', () => {
|
||||
const model = loadModule('device-manager.model', environment());
|
||||
for (const [name, expected] of [
|
||||
['switch', 'switch'], ['gateway', 'router'], ['Cambridge-PC', 'computer'],
|
||||
['spring', 'unknown'], ['bookmark', 'unknown'], ['canvas', 'unknown'],
|
||||
['nintendo-switch', 'game'], ['switch-PC', 'computer']
|
||||
]) assert.equal(model.detectDeviceType('12:34:56:78:9A:BC', name), expected, name);
|
||||
assert.equal(model.detectDeviceType('02:11:32:11:22:33', ''), 'unknown');
|
||||
assert.equal(model.detectDeviceType('02:11:32:11:22:33', 'synology'), 'nas');
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { environment, loadModule, backend } = require('./helpers/runtime');
|
||||
const mac = 'AA:BB:CC:11:22:33', other = 'AA:BB:CC:44:55:66';
|
||||
const model = loadModule('device-manager.model', environment());
|
||||
const device = { '.type': 'device', '.name': 'dev_aabbcc112233', mac, name: 'TV', remark: 'Living room' };
|
||||
const group = { '.type': 'group', '.name': 'home', name: 'Home' };
|
||||
const data = overrides => Object.assign({ hints: {}, leases: {}, neighbors: { neighbors: [] }, wifi: [], arp: [], devices: [], discoveryComplete: true }, overrides);
|
||||
const parse = input => model.parseDevices(data(input), model.parseGroups(input.groups || []));
|
||||
function service(initial) {
|
||||
const mocks = backend(initial);
|
||||
const env = environment(mocks);
|
||||
return Object.assign({ env, service: loadModule('device-manager.service', env) }, mocks);
|
||||
}
|
||||
|
||||
test('MAC normalization, stable section ID and control-character removal', () => {
|
||||
for (const value of ['aa:bb:cc:11:22:33', 'aa-bb-cc-11-22-33', 'aabbcc112233']) assert.equal(model.normalizeMac(value), mac);
|
||||
for (const value of [null, 123, 'not-a-mac', '00:00:00:00:00:00', 'FF:FF:FF:FF:FF:FF']) assert.equal(model.normalizeMac(value), null);
|
||||
assert.equal(model.getSectionId(mac), 'dev_aabbcc112233');
|
||||
assert.equal(model.sanitizeInput(' hi\nthere\x01 '), 'hi there');
|
||||
});
|
||||
|
||||
test('empty group configuration remains empty', () => assert.deepEqual(model.parseGroups([]), []));
|
||||
test('Wi-Fi-only, neighbor-only and ARP-only devices are discovered', () => {
|
||||
assert.equal(parse({ wifi: [mac] })[0].status, 'online');
|
||||
const neighbor = parse({ neighbors: { neighbors: [{ mac, ip: '192.168.1.10', state: 'REACHABLE' }] } });
|
||||
assert.equal(neighbor[0].status, 'online'); assert.equal(neighbor[0].ipv4, '192.168.1.10');
|
||||
assert.equal(parse({ arp: [{ mac, ip: '192.168.1.10', flags: '0x2' }] })[0].status, 'unknown');
|
||||
});
|
||||
test('multiple evidence sources deduplicate a MAC and preserve user metadata', () => {
|
||||
const result = parse({ hints: { [mac.toLowerCase()]: { name: 'hostname', ipaddrs: ['192.168.1.20'] } },
|
||||
wifi: [mac], devices: [Object.assign({}, device, { group: 'deleted' })] });
|
||||
assert.equal(result.length, 1); assert.equal(result[0].customName, 'TV');
|
||||
assert.equal(result[0].remark, 'Living room'); assert.equal(result[0].group, 'ungrouped');
|
||||
});
|
||||
test('active neighbor evidence outranks FAILED and STALE in every input order', () => {
|
||||
for (const active of ['REACHABLE', 'DELAY', 'PROBE']) {
|
||||
for (const states of [['FAILED', active], [active, 'FAILED'], ['STALE', active], [active, 'STALE']]) {
|
||||
assert.equal(parse({ neighbors: { neighbors: states.map(state => ({ mac, state })) } })[0].status, 'online');
|
||||
}
|
||||
}
|
||||
});
|
||||
test('FAILED neighbors without MAC are associated to a unique known IP', () => {
|
||||
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } },
|
||||
neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'br-lan', state: 'FAILED' }] } });
|
||||
assert.equal(result[0].status, 'offline');
|
||||
});
|
||||
test('ambiguous IP ownership never assigns a failure to either device', () => {
|
||||
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] }, [other]: { ipaddrs: ['192.168.1.20'] } },
|
||||
neighbors: { neighbors: [{ ip: '192.168.1.20', state: 'FAILED' }] } });
|
||||
assert.ok(result.every(item => item.status === 'unknown'));
|
||||
});
|
||||
test('STALE, PERMANENT and unresolved discovery failures are unknown', () => {
|
||||
for (const state of ['STALE', 'PERMANENT', 'INCOMPLETE']) assert.equal(parse({ neighbors: { neighbors: [{ mac, state }] } })[0].status, 'unknown');
|
||||
assert.equal(parse({ devices: [device], discoveryComplete: false })[0].status, 'unknown');
|
||||
assert.equal(parse({ devices: [device] })[0].status, 'offline');
|
||||
assert.equal(parse({ devices: [device], discoveryComplete: false, wifi: [mac] })[0].status, 'online');
|
||||
});
|
||||
test('current active IP and DHCP leases take precedence over stale hints', () => {
|
||||
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, leases: { dhcp_leases: [{ macaddr: mac, ipaddr: '192.168.1.90' }] } });
|
||||
assert.equal(result[0].ipv4, '192.168.1.90');
|
||||
assert.equal(parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, neighbors: { neighbors: [{ mac, ip: '192.168.1.91', state: 'REACHABLE' }] } })[0].ipv4, '192.168.1.91');
|
||||
});
|
||||
test('IPv6 leases and all addresses participate in failed neighbor matching', () => {
|
||||
const result = parse({ leases: { dhcp6_leases: [{ macaddr: mac, ip6addrs: ['fd00::2/64', 'fd00::3'] }] },
|
||||
neighbors: { neighbors: [{ ip: 'fd00::3', state: 'FAILED' }] } });
|
||||
assert.equal(result[0].ipv6, 'fd00::2'); assert.equal(result[0].status, 'offline');
|
||||
});
|
||||
test('combined group and text filtering uses names, remarks and addresses', () => {
|
||||
const result = parse({ devices: [Object.assign({}, device, { group: 'home' })], groups: [group] });
|
||||
assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'living'));
|
||||
assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'home'));
|
||||
assert.equal(model.matchesDevice(result[0], [], 'ungrouped', ''), false);
|
||||
});
|
||||
test('legacy sections recover their MAC from the section ID', () => {
|
||||
assert.equal(parse({ devices: [{ '.name': 'dev_aabbcc112233', name: 'Legacy' }] })[0].mac, mac);
|
||||
});
|
||||
|
||||
test('real service saves a MAC-only record and clearing metadata preserves it', async () => {
|
||||
const ctx = service([]);
|
||||
await ctx.service.saveDevice(mac, '', '', 'ungrouped');
|
||||
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].mac, mac);
|
||||
await ctx.service.saveDevice(mac, 'TV', 'Room', 'ungrouped');
|
||||
await ctx.service.saveDevice(mac, '', '', 'ungrouped');
|
||||
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].name, undefined);
|
||||
});
|
||||
test('group create, rename and delete preserve actual device name and remarks', async () => {
|
||||
const ctx = service([device]);
|
||||
await ctx.service.addGroup('Home');
|
||||
const created = ctx.state.saved.find(section => section['.type'] === 'group');
|
||||
await ctx.service.saveDevice(mac, 'TV', 'Living room', created['.name']);
|
||||
await ctx.service.renameGroup(created['.name'], 'Media');
|
||||
assert.equal(ctx.state.saved.find(section => section.mac === mac).group, created['.name']);
|
||||
await ctx.service.deleteGroup(created['.name']);
|
||||
assert.deepEqual(ctx.state.saved, [device]);
|
||||
assert.deepEqual((await ctx.service.load()).groups, []);
|
||||
});
|
||||
test('duplicate device sections are consolidated and deletion removes all matches', async () => {
|
||||
const ctx = service([device, Object.assign({}, device, { '.name': 'legacy' })]);
|
||||
await ctx.service.saveDevice(mac, 'Edited', '', 'ungrouped');
|
||||
assert.equal(ctx.state.saved.length, 1);
|
||||
await ctx.service.deleteDevice({ mac }); assert.equal(ctx.state.saved.length, 0);
|
||||
});
|
||||
test('commit failure rejects, reverts staged changes and permits a clean retry', async () => {
|
||||
const ctx = service([device]); ctx.state.failCommit = true;
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /commit failed/);
|
||||
assert.deepEqual(ctx.state.saved, [device]); assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1);
|
||||
assert.equal(ctx.state.declarations.find(options => options.method === 'commit').reject, true);
|
||||
ctx.state.failCommit = false;
|
||||
await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped');
|
||||
assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV');
|
||||
});
|
||||
test('partial save failure also discards staged edits', async () => {
|
||||
const ctx = service([device]); ctx.state.failSave = true;
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /save failed/);
|
||||
assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1);
|
||||
});
|
||||
test('rollback failure is visible instead of reporting a successful save', async () => {
|
||||
const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true;
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /failed to discard pending changes/);
|
||||
});
|
||||
test('fresh UCI load observes external edits and validation rejects deleted groups', async () => {
|
||||
const ctx = service([group, device]); await ctx.service.load();
|
||||
ctx.state.saved[1].name = 'External edit';
|
||||
assert.equal((await ctx.service.load()).devices[0].name, 'External edit');
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'TV', '', 'missing'), /no longer exists/);
|
||||
assert.equal(ctx.state.commits, 0);
|
||||
});
|
||||
test('renaming into an existing group name and overlong inputs are rejected', async () => {
|
||||
const ctx = service([group, { '.type': 'group', '.name': 'other', name: 'Other' }]);
|
||||
await assert.rejects(ctx.service.renameGroup('other', 'Home'), /already exists/);
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'a'.repeat(65), '', 'ungrouped'), /too long/);
|
||||
});
|
||||
test('service refuses writes for read-only sessions', async () => {
|
||||
const ctx = service([device]); ctx.env.L.hasViewPermission = () => false;
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'Forbidden', '', 'ungrouped'), /Read-only/);
|
||||
assert.equal(ctx.state.reads, 0); assert.equal(ctx.state.commits, 0);
|
||||
});
|
||||
test('refresh is serialized after an in-flight write and does not unload it', async () => {
|
||||
const ctx = service([device]); let release, started;
|
||||
const signal = new Promise(resolve => { started = resolve; });
|
||||
const blocker = new Promise(resolve => { release = resolve; });
|
||||
const save = ctx.uci.save;
|
||||
ctx.uci.save = () => { started(); return blocker.then(save); };
|
||||
const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped');
|
||||
await signal;
|
||||
const reading = ctx.service.load();
|
||||
assert.equal(ctx.state.cached[0].name, 'New name');
|
||||
release(); await writing;
|
||||
assert.equal((await reading).devices[0].name, 'New name');
|
||||
});
|
||||
test('every refresh fetches current host hints without network library cache', async () => {
|
||||
const ctx = service([]);
|
||||
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.20'] } };
|
||||
await ctx.service.load();
|
||||
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.90'] } };
|
||||
assert.equal(model.parseDevices(await ctx.service.load(), [])[0].ipv4, '192.168.1.90');
|
||||
});
|
||||
test('discovery errors are reported and do not turn saved devices offline', async () => {
|
||||
const ctx = service([device]); ctx.replies['luci-rpc.getHostHints'] = new Error('access denied');
|
||||
const snapshot = await ctx.service.load();
|
||||
assert.equal(snapshot.discoveryComplete, false); assert.match(snapshot.errors.join(), /access denied/);
|
||||
assert.equal(model.parseDevices(snapshot, [])[0].status, 'unknown');
|
||||
ctx.replies['luci.device-manager.get_online_status'] = { ok: false, error: 'ip unavailable' };
|
||||
assert.match((await ctx.service.load()).errors.join(), /ip unavailable/);
|
||||
});
|
||||
test('partial Wi-Fi RPC failure preserves active stations and VLAN discovery', async () => {
|
||||
const ctx = service([]);
|
||||
ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [
|
||||
{ ifname: 'wlan0', config: { mode: 'ap' }, vlans: [{ ifname: 'wlan0.10' }] },
|
||||
{ ifname: 'wlan1', config: { mode: 'ap' } }, { ifname: 'uplink', config: { mode: 'sta' } }
|
||||
] } };
|
||||
ctx.replies['iwinfo.assoclist'] = name => name === 'wlan1' ? new Error('radio unavailable') : { results: [{ mac }] };
|
||||
const snapshot = await ctx.service.load();
|
||||
assert.equal(snapshot.discoveryComplete, false); assert.equal(model.parseDevices(snapshot, [])[0].status, 'online');
|
||||
assert.ok(ctx.state.calls.some(call => call.key === 'iwinfo.assoclist' && call.args[0] === 'wlan0.10'));
|
||||
assert.ok(!ctx.state.calls.some(call => call.args[0] === 'uplink'));
|
||||
});
|
||||
|
||||
test('preference corruption and inaccessible storage safely fall back', async () => {
|
||||
for (const storage of [{ getItem: () => '{broken' }, { getItem() { throw Error('blocked'); } }]) {
|
||||
const env = environment({ window: { localStorage: storage } });
|
||||
const preferences = loadModule('device-manager.preferences', env);
|
||||
assert.deepEqual(await preferences.load(), { tab: 'online', group: 'all' });
|
||||
await preferences.save('online', 'home');
|
||||
}
|
||||
});
|
||||
test('preference storage contains only tab and group', async () => {
|
||||
let raw;
|
||||
const env = environment({ window: { localStorage: { getItem: () => raw, setItem: (key, value) => { raw = value; } } } });
|
||||
const preferences = loadModule('device-manager.preferences', env);
|
||||
await preferences.save('online', 'home');
|
||||
assert.deepEqual(JSON.parse(raw), { tab: 'online', group: 'home' });
|
||||
assert.deepEqual(await preferences.load(), { tab: 'online', group: 'home' });
|
||||
});
|
||||
|
||||
function pageContext(initial = []) {
|
||||
const mocks = backend(initial);
|
||||
const env = environment(mocks);
|
||||
return Object.assign({ env, page: loadModule('view.device-manager.devices', env) }, mocks);
|
||||
}
|
||||
async function renderPage(ctx) {
|
||||
const node = ctx.page.render(await ctx.page.load());
|
||||
return node;
|
||||
}
|
||||
function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); }
|
||||
const attack = '<img src=x onerror="window.auditFlag=1">';
|
||||
|
||||
test('untrusted device/group text renders as text in rows, forms and confirmations', async () => {
|
||||
const ctx = pageContext([Object.assign({}, group, { name: attack }), Object.assign({}, device, { name: attack, remark: attack, group: 'home' })]);
|
||||
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { name: attack, ipaddrs: ['192.168.1.20'] } };
|
||||
const node = await renderPage(ctx);
|
||||
assert.ok(node.textContent.includes(attack));
|
||||
ctx.page.showEditModal(ctx.page.devices[0]);
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
|
||||
ctx.page.confirmDelete(ctx.page.devices[0]);
|
||||
ctx.page.showGroupModal(); ctx.page.promptRenameGroup(ctx.page.groups[0]);
|
||||
ctx.page.confirmDeleteGroup(ctx.page.groups[0], 1);
|
||||
assert.deepEqual(ctx.env.htmlSinks, []);
|
||||
});
|
||||
test('search empty-state messages safely display markup-like search text', async () => {
|
||||
const ctx = pageContext([]); const node = await renderPage(ctx);
|
||||
ctx.page.filterText = attack; ctx.page.updateView();
|
||||
assert.ok(node.textContent.includes(attack)); assert.deepEqual(ctx.env.htmlSinks, []);
|
||||
});
|
||||
test('actual save button recovers after event.currentTarget is cleared', async () => {
|
||||
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null);
|
||||
const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac;
|
||||
const save = button(modal, 'Save');
|
||||
await save.click();
|
||||
assert.equal(save.disabled, false); assert.equal(save.classes.has('spinning'), false);
|
||||
assert.equal(ctx.state.saved[0].mac, mac); assert.equal(ctx.env.notifications[0].level, 'info');
|
||||
});
|
||||
test('save failure leaves the modal open, restores button and never notifies success', async () => {
|
||||
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null);
|
||||
const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac;
|
||||
ctx.state.failCommit = true;
|
||||
const save = button(modal, 'Save'); await save.click();
|
||||
assert.equal(ctx.env.ui.modal, modal); assert.equal(save.disabled, false);
|
||||
assert.equal(ctx.env.notifications.length, 0); assert.match(modal.textContent, /commit failed/);
|
||||
assert.equal(ctx.state.saved.length, 0);
|
||||
});
|
||||
test('actual add, rename and delete group buttons restore their state', async () => {
|
||||
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showGroupModal();
|
||||
const modal = ctx.env.ui.modal; modal.querySelector('input').value = 'Home';
|
||||
const add = button(modal, 'Add group'); await add.click(); assert.equal(add.disabled, false);
|
||||
ctx.page.promptRenameGroup(ctx.page.groups[0]);
|
||||
ctx.env.ui.modal.querySelector('input').value = 'Media';
|
||||
const rename = button(ctx.env.ui.modal, 'Save changes'); await rename.click(); assert.equal(rename.disabled, false);
|
||||
ctx.page.confirmDeleteGroup(ctx.page.groups[0], 0);
|
||||
const remove = button(ctx.env.ui.modal, 'Confirm delete'); await remove.click(); assert.equal(remove.disabled, false);
|
||||
assert.deepEqual(ctx.page.groups, []);
|
||||
});
|
||||
test('actual clear record button restores its state on failure and retry', async () => {
|
||||
const ctx = pageContext([device]); await renderPage(ctx); ctx.page.confirmDelete(ctx.page.devices[0]);
|
||||
const remove = button(ctx.env.ui.modal, 'Confirm clear'); ctx.state.failCommit = true;
|
||||
await remove.click(); assert.equal(remove.disabled, false); assert.equal(ctx.state.saved.length, 1);
|
||||
ctx.state.failCommit = false; ctx.page.confirmDelete(ctx.page.devices[0]);
|
||||
const retry = button(ctx.env.ui.modal, 'Confirm clear'); await retry.click();
|
||||
assert.equal(retry.disabled, false); assert.equal(ctx.state.saved.length, 0);
|
||||
});
|
||||
test('refresh button restores state and external group deletion resets the active filter', async () => {
|
||||
const ctx = pageContext([group]); const node = await renderPage(ctx);
|
||||
ctx.page.activeGroup = 'home'; ctx.state.saved = [];
|
||||
const refresh = button(node, 'Refresh list'); await refresh.click();
|
||||
assert.equal(refresh.disabled, false); assert.equal(ctx.page.activeGroup, 'all');
|
||||
assert.deepEqual(ctx.page.groups, []);
|
||||
});
|
||||
test('failed refresh preserves the last displayed snapshot and reports refresh failure', async () => {
|
||||
const ctx = pageContext([device]); const node = await renderPage(ctx);
|
||||
ctx.uci.load = () => Promise.reject(new Error('configuration unavailable'));
|
||||
await button(node, 'Refresh list').click();
|
||||
assert.equal(ctx.page.devices[0].customName, 'TV');
|
||||
assert.match(ctx.env.notifications[0].content.textContent, /Could not refresh/);
|
||||
});
|
||||
test('read-only views have no edit actions or writable dialogs and disable the toolbar', async () => {
|
||||
const ctx = pageContext([device]); ctx.env.L.hasViewPermission = () => false;
|
||||
const node = await renderPage(ctx);
|
||||
ctx.page.activeTab = 'all'; ctx.page.updateView();
|
||||
assert.equal(button(node, 'Edit'), undefined); assert.equal(button(node, 'Delete'), undefined);
|
||||
assert.equal(node.querySelectorAll('.dm-action-edit').length, 0);
|
||||
assert.equal(node.querySelectorAll('.dm-action-detail').length, 1);
|
||||
assert.equal(button(node, 'Add device').disabled, true); assert.equal(button(node, 'Manage groups').disabled, true);
|
||||
ctx.page.showEditModal(null); ctx.page.showGroupModal(); assert.equal(ctx.env.ui.modal, null);
|
||||
assert.equal(ctx.page.handleSave, null); assert.equal(ctx.page.handleSaveApply, null); assert.equal(ctx.page.handleReset, null);
|
||||
});
|
||||
test('rows offer edit/details links and saved records can only be cleared from the edit dialog', async () => {
|
||||
const ctx = pageContext([device]);
|
||||
ctx.replies['luci-rpc.getHostHints'] = { [other]: { name: 'New device' } };
|
||||
const node = await renderPage(ctx);
|
||||
ctx.page.activeTab = 'all'; ctx.page.updateView();
|
||||
const rows = node.querySelector('#device_manager_tbody').querySelectorAll('tr');
|
||||
const savedRow = rows.find(row => row.textContent.includes('TV'));
|
||||
const unsavedRow = rows.find(row => row.textContent.includes('New device'));
|
||||
assert.equal(node.querySelectorAll('.dm-action-delete').length, 0);
|
||||
assert.equal(unsavedRow.querySelectorAll('a').length, 2);
|
||||
assert.equal(savedRow.querySelectorAll('a').length, 2);
|
||||
const edit = savedRow.querySelector('.dm-action-edit');
|
||||
const detail = savedRow.querySelector('.dm-action-detail');
|
||||
assert.equal(edit.attrs.href, '#'); assert.equal(edit.attrs.role, 'button');
|
||||
const click = new Event('click', { cancelable: true });
|
||||
edit.dispatchEvent(click);
|
||||
assert.equal(click.defaultPrevented, true);
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
|
||||
assert.equal(ctx.env.ui.modal.querySelector('input').value, 'TV');
|
||||
await button(ctx.env.ui.modal, 'Clear record').click();
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Clear record');
|
||||
await detail.click();
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Device details');
|
||||
const space = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(space, 'key', { value: ' ' });
|
||||
edit.dispatchEvent(space);
|
||||
assert.equal(space.defaultPrevented, true);
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
|
||||
await unsavedRow.querySelector('.dm-action-edit').click();
|
||||
assert.equal(button(ctx.env.ui.modal, 'Clear record'), undefined);
|
||||
});
|
||||
test('tab counts match group/search filters and tabs support keyboard activation', async () => {
|
||||
const ctx = pageContext([group, Object.assign({}, device, { group: 'home' })]);
|
||||
ctx.replies['iwinfo.assoclist'] = { results: [{ mac: other }] };
|
||||
ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [{ ifname: 'wlan0' }] } };
|
||||
const node = await renderPage(ctx);
|
||||
const tabs = node.querySelector('#dm-tabs-container').children;
|
||||
assert.equal(tabs[0].textContent, 'Online devices1'); assert.equal(tabs[1].textContent, 'All devices2');
|
||||
assert.ok(tabs.every(tab => tab.tag === 'button' && tab.attrs.role === 'tab'));
|
||||
ctx.page.activeGroup = 'home'; ctx.page.filterText = 'tv'; ctx.page.updateView();
|
||||
assert.equal(node.querySelector('#dm-tabs-container').children[0].textContent, 'Online devices0');
|
||||
assert.equal(node.querySelector('#dm-tabs-container').children[1].textContent, 'All devices1');
|
||||
});
|
||||
|
||||
test('a failed rollback must recover before a later operation can commit', async () => {
|
||||
const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true;
|
||||
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'));
|
||||
ctx.state.failCommit = false;
|
||||
await assert.rejects(ctx.service.saveDevice(other, 'Phone', '', 'ungrouped'), /revert failed/);
|
||||
assert.deepEqual(ctx.state.saved, [device]);
|
||||
ctx.state.failRevert = false;
|
||||
await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped');
|
||||
assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV');
|
||||
});
|
||||
test('a write waits for the entire earlier refresh to finish, including slow discovery', async () => {
|
||||
const ctx = service([device]); let release, started;
|
||||
const signal = new Promise(resolve => { started = resolve; });
|
||||
const blocker = new Promise(resolve => { release = resolve; });
|
||||
ctx.fs.read = () => { started(); return blocker.then(() => ''); };
|
||||
const reading = ctx.service.load(); await signal;
|
||||
const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped');
|
||||
await Promise.resolve(); assert.equal(ctx.state.commits, 0);
|
||||
release(); assert.equal((await reading).devices[0].name, 'TV');
|
||||
await writing; assert.equal(ctx.state.saved[0].name, 'New name');
|
||||
});
|
||||
|
||||
test('MAC-less failures on a different interface do not mark an ARP-known device offline', () => {
|
||||
const result = parse({ arp: [{ mac, ip: '192.168.1.20', flags: '0x2', dev: 'br-lan' }],
|
||||
neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'wan', state: 'FAILED' }] } });
|
||||
assert.equal(result[0].status, 'unknown');
|
||||
});
|
||||
test('switching a status tab preserves focus on the newly rendered tab', async () => {
|
||||
const ctx = pageContext([]); const node = await renderPage(ctx);
|
||||
await node.querySelector('#dm-tabs-container').children[1].click();
|
||||
assert.equal(ctx.page.activeTab, 'all');
|
||||
assert.equal(node.querySelector('#dm-tabs-container').children[1].focused, true);
|
||||
});
|
||||
|
||||
test('writable toolbar actions remain enabled under LuCI boolean attribute semantics', async () => {
|
||||
const ctx = pageContext([]); const node = await renderPage(ctx);
|
||||
assert.equal(button(node, 'Add device').disabled, false);
|
||||
assert.equal(button(node, 'Manage groups').disabled, false);
|
||||
await button(node, 'Add device').click();
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, 'Add device');
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { environment, loadModule, backend, root } = require('./helpers/runtime');
|
||||
|
||||
function catalogue() {
|
||||
const messages = {};
|
||||
let key = '', value = '', field = '';
|
||||
for (const line of (fs.readFileSync(path.join(root, 'po/zh_Hans/device-manager.po'), 'utf8') + '\n\n').split('\n')) {
|
||||
if (line.startsWith('msgid ')) { key = JSON.parse(line.slice(6)); field = 'key'; }
|
||||
else if (line.startsWith('msgstr ')) { value = JSON.parse(line.slice(7)); field = 'value'; }
|
||||
else if (line.startsWith('"')) {
|
||||
if (field === 'key') key += JSON.parse(line);
|
||||
else if (field === 'value') value += JSON.parse(line);
|
||||
} else if (!line.trim()) {
|
||||
if (key && value) messages[key] = value;
|
||||
key = ''; value = ''; field = '';
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
function context(translate = text => text, initial = []) {
|
||||
const mocks = backend(initial);
|
||||
const env = environment(Object.assign({}, mocks, {
|
||||
_: translate,
|
||||
window: { navigator: { languages: ['zh-CN'] }, localStorage: { getItem: () => null, setItem() {} } }
|
||||
}));
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
return { mocks, env, page };
|
||||
}
|
||||
async function render(ctx) { return ctx.page.render(await ctx.page.load()); }
|
||||
function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); }
|
||||
|
||||
test('native LuCI catalogue translates the page, sort titles, types and dialogs', async () => {
|
||||
const messages = catalogue();
|
||||
const ctx = context(text => messages[text] || text, [
|
||||
{ '.type': 'device', '.name': 'saved', mac: 'AA:BB:CC:11:22:33', name: '客厅电视', type: 'tv' }
|
||||
]);
|
||||
const node = await render(ctx);
|
||||
assert.ok(button(node, messages['Refresh list']));
|
||||
assert.ok(button(node, messages['Add device']));
|
||||
assert.ok(button(node, messages['Hide info']));
|
||||
assert.ok(button(node, messages['Scan LAN']));
|
||||
const title = node.querySelectorAll('th').find(th => th.getAttribute('data-sort') === 'name').querySelector('button').attrs.title;
|
||||
assert.equal(title, messages['Click to sort by %s'].format(messages['Device name']));
|
||||
ctx.page.showEditModal(ctx.page.devices[0]);
|
||||
assert.equal(ctx.env.ui.modal.attrs.title, messages['Edit device']);
|
||||
assert.ok(button(ctx.env.ui.modal, messages.Save));
|
||||
assert.ok(ctx.env.ui.modal.querySelectorAll('option').some(option => option.textContent === messages.TV));
|
||||
assert.deepEqual(ctx.env.htmlSinks, []);
|
||||
});
|
||||
test('LuCI decides language without a browser override or custom language RPC', async () => {
|
||||
for (const translate of [text => text, text => 'DE:' + text, text => '繁體:' + text]) {
|
||||
const ctx = context(translate);
|
||||
const node = await render(ctx);
|
||||
assert.ok(button(node, translate('Refresh list')));
|
||||
assert.ok(node.textContent.includes(translate('LAN Device Management')));
|
||||
assert.ok(!ctx.mocks.state.calls.some(call => call.key.includes('get_language')));
|
||||
}
|
||||
});
|
||||
test('legacy default group labels translate while custom metadata remains literal', async () => {
|
||||
const initial = [
|
||||
{ '.type': 'group', '.name': 'smart_home', name: '智能家居' },
|
||||
{ '.type': 'group', '.name': 'phone', name: 'My phones' },
|
||||
{ '.type': 'group', '.name': 'grp_custom', name: '办公设备' }
|
||||
];
|
||||
const ctx = context(text => 'DE:' + text, initial);
|
||||
await render(ctx);
|
||||
assert.deepEqual(ctx.page.groups.map(group => group.name), ['DE:Smart home', 'My phones', '办公设备']);
|
||||
assert.deepEqual(ctx.mocks.state.saved, initial);
|
||||
});
|
||||
test('loading and refreshing never start active scans; the explicit button scans then refreshes', async () => {
|
||||
const ctx = context(); const node = await render(ctx);
|
||||
await ctx.page.refresh();
|
||||
assert.equal(ctx.mocks.state.calls.filter(call => call.key.endsWith('.scan_devices')).length, 0);
|
||||
await button(node, 'Scan LAN').click();
|
||||
const keys = ctx.mocks.state.calls.map(call => call.key);
|
||||
assert.equal(keys.filter(key => key.endsWith('.scan_devices')).length, 1);
|
||||
assert.ok(keys.lastIndexOf('luci.device-manager.get_online_status') > keys.indexOf('luci.device-manager.scan_devices'));
|
||||
assert.equal(button(node, 'Scan LAN').disabled, false);
|
||||
});
|
||||
test('read-only users cannot start scans and scan failures restore the control', async () => {
|
||||
const ctx = context();
|
||||
ctx.env.L.hasViewPermission = () => false;
|
||||
const node = await render(ctx);
|
||||
assert.equal(button(node, 'Scan LAN').disabled, true);
|
||||
await ctx.page.scan();
|
||||
assert.ok(!ctx.mocks.state.calls.some(call => call.key.endsWith('.scan_devices')));
|
||||
ctx.env.L.hasViewPermission = () => true;
|
||||
ctx.page.readonly = false;
|
||||
const writable = ctx.page.render(await ctx.page.load());
|
||||
ctx.mocks.replies['luci.device-manager.scan_devices'] = { ok: false };
|
||||
await button(writable, 'Scan LAN').click();
|
||||
assert.equal(button(writable, 'Scan LAN').disabled, false);
|
||||
assert.match(ctx.env.notifications.at(-1).content.textContent, /Could not scan LAN devices/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
'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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'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 { spawn, spawnSync } = require('node:child_process');
|
||||
const { root } = require('./helpers/runtime');
|
||||
|
||||
function fixture(t, command, prefix) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-probe-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const clock = path.join(directory, 'uptime');
|
||||
fs.writeFileSync(clock, '100.00 0.00\n');
|
||||
fs.writeFileSync(path.join(directory, 'arp'), '192.168.1.200 0x1 0x2 aa:bb:cc:11:22:33 * br-lan\n');
|
||||
fs.writeFileSync(path.join(directory, 'leases'), '123 aa:bb:cc:11:22:33 192.168.1.200 known *\n123 aa:bb:cc:11:22:44 192.168.3.20 other *\n');
|
||||
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-probe', path.join(directory, 'probe'))
|
||||
.replaceAll('/proc/uptime', clock)
|
||||
.replaceAll('/proc/net/arp', path.join(directory, 'arp'))
|
||||
.replaceAll('/tmp/dhcp.leases', path.join(directory, 'leases'))
|
||||
.replaceAll('/var/dhcp.leases', path.join(directory, 'leases'));
|
||||
const wrapper = `
|
||||
ip() {
|
||||
if [ "$1" = -o ]; then
|
||||
case "$6" in
|
||||
br-lan) printf '%s\\n' "2: br-lan inet $AUDIT_LAN_CIDR scope global br-lan" ;;
|
||||
br-guest) printf '%s\\n' "3: br-guest inet $AUDIT_GUEST_CIDR scope global br-guest" ;;
|
||||
esac
|
||||
elif [ "$1" = -4 ]; then
|
||||
printf '%s\\n' '198.51.100.0/24 dev eth0 scope link'
|
||||
else
|
||||
printf '%s\\n' '192.168.1.200 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE'
|
||||
fi
|
||||
}
|
||||
uci() { printf '%s\\n' "$AUDIT_INTERFACES"; }
|
||||
ubus() {
|
||||
[ "$AUDIT_NO_INTERFACE" = 1 ] && return 1
|
||||
case "$2" in
|
||||
network.interface.lan) printf '%s\\n' '{"l3_device":"br-lan"}' ;;
|
||||
network.interface.guest) printf '%s\\n' '{"l3_device":"br-guest"}' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
ping() {
|
||||
previous=""; interface=""
|
||||
for argument in "$@"; do
|
||||
[ "$previous" = -I ] && interface="$argument"
|
||||
previous="$argument"; target="$argument"
|
||||
done
|
||||
case "$interface" in br-lan|br-guest) ;; *) return 2 ;; esac
|
||||
case "$target" in
|
||||
192.168.1.200|192.168.3.20) sleep "\${AUDIT_KNOWN_START_DELAY:-0}" ;;
|
||||
esac
|
||||
printf 'start %s\\n' "$target" >> "$AUDIT_PING_LOG"
|
||||
sleep "$AUDIT_PING_DELAY"
|
||||
if [ -n "$AUDIT_ADVANCE" ]; then printf '%s.00 0.00\\n' "$AUDIT_ADVANCE" > "$AUDIT_CLOCK"; fi
|
||||
printf 'end %s\\n' "$target" >> "$AUDIT_PING_LOG"
|
||||
return 1
|
||||
}
|
||||
` + source;
|
||||
let sequence = 0;
|
||||
return {
|
||||
clock,
|
||||
directory,
|
||||
run(extra = {}, method = 'scan_devices') {
|
||||
const id = sequence++;
|
||||
const log = path.join(directory, 'pings-' + id);
|
||||
const child = spawn(command, [...prefix, '-c', wrapper, 'probe-test', 'call', method], {
|
||||
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_PING_DELAY: '0.02', AUDIT_KNOWN_START_DELAY: '0',
|
||||
AUDIT_ADVANCE: '', AUDIT_CLOCK: clock, AUDIT_INTERFACES: 'lan guest', AUDIT_NO_INTERFACE: '0',
|
||||
AUDIT_LAN_CIDR: '192.168.1.1/24', AUDIT_GUEST_CIDR: '192.168.2.1/24'
|
||||
}, extra)
|
||||
});
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.on('data', data => { stdout += data; });
|
||||
child.stderr.on('data', data => { stderr += data; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
|
||||
const done = new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) { reject(new Error(stderr || 'RPC exit: ' + code)); return; }
|
||||
resolve({ reply: JSON.parse(stdout), log });
|
||||
});
|
||||
});
|
||||
return { done, log };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function starts(log) {
|
||||
if (!fs.existsSync(log)) return [];
|
||||
return fs.readFileSync(log, 'utf8').trim().split('\n')
|
||||
.filter(line => line.startsWith('start ')).map(line => line.slice(6));
|
||||
}
|
||||
|
||||
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
|
||||
const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0;
|
||||
test(`${command}: active probes are bounded, deduplicated, cooled down and resume the sweep`, { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const seen = new Set();
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
fs.writeFileSync(f.clock, `${100 + pass * 30}.00 0.00\n`);
|
||||
// Delay known-host children to expose assertions that depend on scheduling order.
|
||||
const result = await f.run({ AUDIT_KNOWN_START_DELAY: '0.08' }).done;
|
||||
assert.equal(result.reply.ok, true); // Failed ICMP must still return the neighbor snapshot.
|
||||
const addresses = starts(result.log);
|
||||
assert.equal(addresses.length, 128);
|
||||
assert.equal(new Set(addresses).size, 128);
|
||||
if (pass === 0) {
|
||||
// Children may log in any order; the batch barrier preserves batch membership.
|
||||
const firstBatch = ['192.168.1.200',
|
||||
...Array.from({ length: 15 }, (_, i) => '192.168.1.' + (i + 2))];
|
||||
assert.deepEqual(addresses.slice(0, 16).sort(), firstBatch.sort(),
|
||||
'known hosts must be included in the first batch');
|
||||
}
|
||||
if (pass === 1) assert.ok(addresses.every(ip => !seen.has(ip)), 'second pass must resume, not restart');
|
||||
addresses.forEach(ip => seen.add(ip));
|
||||
let active = 0, peak = 0;
|
||||
for (const line of fs.readFileSync(result.log, 'utf8').trim().split('\n')) {
|
||||
active += line.startsWith('start ') ? 1 : -1;
|
||||
peak = Math.max(peak, active);
|
||||
}
|
||||
assert.ok(peak <= 16, `peak concurrency was ${peak}`);
|
||||
assert.equal(active, 0, 'all probes must finish before releasing the lock');
|
||||
const cooldown = await f.run().done;
|
||||
assert.equal(cooldown.reply.ok, true);
|
||||
assert.deepEqual(starts(cooldown.log), []);
|
||||
}
|
||||
assert.equal(seen.size, 506, 'selected /24 subnets exclude router addresses and out-of-scope known hosts');
|
||||
});
|
||||
|
||||
test(`${command}: overlapping RPCs share one scan and the lock is released afterwards`, { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const first = f.run({ AUDIT_PING_DELAY: '0.05' });
|
||||
for (let attempt = 0; attempt < 100 && starts(first.log).length === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.ok(starts(first.log).length > 0, 'first request must have acquired the scan lock');
|
||||
const second = await f.run().done;
|
||||
assert.equal(second.reply.ok, true);
|
||||
assert.deepEqual(starts(second.log), []);
|
||||
await first.done;
|
||||
fs.writeFileSync(f.clock, '130.00 0.00\n');
|
||||
const next = await f.run().done;
|
||||
assert.equal(starts(next.log).length, 128, 'next eligible request must acquire the released lock');
|
||||
});
|
||||
|
||||
test(`${command}: elapsed-time budget stops a scan before its address limit`, { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const result = await f.run({ AUDIT_ADVANCE: '108' }).done;
|
||||
assert.equal(result.reply.ok, true);
|
||||
assert.equal(starts(result.log).length, 16);
|
||||
assert.equal(fs.readFileSync(path.join(f.directory, 'probe.state'), 'utf8'), '108 16\n');
|
||||
fs.writeFileSync(f.clock, '130.00 0.00\n');
|
||||
assert.deepEqual(starts((await f.run().done).log), [], 'cooldown starts after the scan finishes');
|
||||
});
|
||||
test(command + ': passive reads never probe and unavailable LANs never fall back to WAN', { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const passive = await f.run({}, 'get_online_status').done;
|
||||
assert.equal(passive.reply.ok, true);
|
||||
assert.deepEqual(starts(passive.log), []);
|
||||
const unavailable = await f.run({ AUDIT_NO_INTERFACE: '1' }).done;
|
||||
assert.equal(unavailable.reply.ok, false);
|
||||
assert.deepEqual(starts(unavailable.log), []);
|
||||
});
|
||||
test(command + ': /25 and /23 probes respect subnet boundaries and exclude router addresses', { skip: !available }, async t => {
|
||||
for (const cidr of ['192.168.1.130/25', '192.168.1.1/23']) {
|
||||
const f = fixture(t, command, prefix);
|
||||
const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: cidr }).done;
|
||||
assert.equal(result.reply.ok, true);
|
||||
const addresses = starts(result.log);
|
||||
assert.ok(addresses.length > 0);
|
||||
assert.ok(!addresses.includes(cidr.split('/')[0]));
|
||||
assert.ok(!addresses.some(ip => ip.startsWith('198.51.100.') || ip.startsWith('192.168.3.')));
|
||||
if (cidr.endsWith('/25')) {
|
||||
assert.equal(addresses.length, 125);
|
||||
assert.ok(addresses.every(ip => ip.startsWith('192.168.1.') && Number(ip.split('.')[3]) >= 129 && Number(ip.split('.')[3]) <= 254));
|
||||
} else {
|
||||
assert.ok(addresses.every(ip => ip.startsWith('192.168.0.') || ip.startsWith('192.168.1.')));
|
||||
}
|
||||
}
|
||||
});
|
||||
test(command + ': a /16 probes known LAN hosts only and ignores invalid interface names', { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const result = await f.run({ AUDIT_INTERFACES: 'lan ../wan', AUDIT_LAN_CIDR: '192.168.1.1/16' }).done;
|
||||
assert.equal(result.reply.ok, true);
|
||||
assert.deepEqual(starts(result.log).sort(), ['192.168.1.200', '192.168.3.20']);
|
||||
});
|
||||
|
||||
test(command + ': /31 addresses and disabled scanning do not expand to a /24', { skip: !available }, async t => {
|
||||
const f = fixture(t, command, prefix);
|
||||
const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: '192.168.1.130/31' }).done;
|
||||
assert.equal(result.reply.ok, true);
|
||||
assert.deepEqual(starts(result.log), ['192.168.1.131']);
|
||||
fs.writeFileSync(f.clock, '130.00 0.00\n');
|
||||
const disabled = await f.run({ AUDIT_INTERFACES: '' }).done;
|
||||
assert.equal(disabled.reply.ok, false);
|
||||
assert.deepEqual(starts(disabled.log), []);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Offline checks for SDK selection and the actual translation catalogue."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("resolve_sdk", ROOT / "tools/resolve-sdk.py")
|
||||
SDK = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(SDK)
|
||||
|
||||
|
||||
class SDKTests(unittest.TestCase):
|
||||
def test_requested_version_and_target_select_the_corresponding_sdk(self):
|
||||
for version, target in [("24.10.0", "x86/64"), ("23.05.5", "mediatek/filogic")]:
|
||||
filename = f"openwrt-sdk-{version}-test_gcc.Linux-x86_64.tar.zst"
|
||||
checksum = "a" * 64
|
||||
listing = f'<a href="{filename}">{filename}</a><a href="../">Parent</a>'
|
||||
with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(listing.encode()), io.BytesIO(f"{checksum} *{filename}\n".encode())]) as fetch:
|
||||
url, sha = SDK.resolve(version, target)
|
||||
self.assertEqual(url, f"https://downloads.openwrt.org/releases/{version}/targets/{target}/{filename}")
|
||||
self.assertEqual(sha, checksum)
|
||||
self.assertEqual(fetch.call_args_list[0].args[0], f"https://downloads.openwrt.org/releases/{version}/targets/{target}/")
|
||||
|
||||
def test_invalid_inputs_fail_before_any_network_request(self):
|
||||
for version, target in [("$(id)", "x86/64"), ("24.10.0", "../x86"), ("24.10.0", "x86/64\nextra")]:
|
||||
with patch.object(SDK, "urlopen") as fetch:
|
||||
with self.assertRaises(ValueError):
|
||||
SDK.resolve(version, target)
|
||||
fetch.assert_not_called()
|
||||
|
||||
def test_missing_sdk_or_checksum_is_rejected(self):
|
||||
with patch.object(SDK, "urlopen", return_value=io.BytesIO(b"<html></html>")):
|
||||
with self.assertRaisesRegex(ValueError, "found 0"):
|
||||
SDK.resolve("24.10.0", "x86/64")
|
||||
filename = "openwrt-sdk-24.10.0-test.Linux-x86_64.tar.xz"
|
||||
with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(f'<a href="{filename}">SDK</a>'.encode()), io.BytesIO(b"invalid checksum")]):
|
||||
with self.assertRaisesRegex(ValueError, "No checksum"):
|
||||
SDK.resolve("24.10.0", "x86/64")
|
||||
|
||||
def test_catalogues_cover_native_extracted_messages_including_menu_and_acl(self):
|
||||
def ids(content):
|
||||
result = set()
|
||||
key = None
|
||||
for line in content.splitlines():
|
||||
if line.startswith("msgid "):
|
||||
key = json.loads(line[6:])
|
||||
elif key is not None and line.startswith('"'):
|
||||
key += json.loads(line)
|
||||
elif line.startswith("msgstr "):
|
||||
if key:
|
||||
result.add(re.sub(r" +", " ", key.strip()))
|
||||
key = None
|
||||
return result
|
||||
strings = set()
|
||||
for file in (ROOT / "htdocs").rglob("*.js"):
|
||||
extracted = subprocess.run(["xgettext", "--from-code=UTF-8", "--language=JavaScript",
|
||||
"--keyword=_:1", "--keyword=N_:2,3", "--no-wrap", "-o", "-", "-"],
|
||||
input=file.read_text(), capture_output=True, text=True, check=True).stdout
|
||||
strings.update(ids(extracted))
|
||||
for directory in ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"]:
|
||||
for file in (ROOT / directory).glob("*.json"):
|
||||
strings.update(re.findall(r'"(?:title|description)"\s*:\s*"([^"]+)"', file.read_text()))
|
||||
for file in [ROOT / "po/templates/device-manager.pot", ROOT / "po/zh_Hans/device-manager.po"]:
|
||||
self.assertEqual(ids(file.read_text()), strings)
|
||||
|
||||
def test_snapshot_sdk_uses_snapshot_directory_and_unversioned_sdk_name(self):
|
||||
filename = "openwrt-sdk-x86-64_gcc-14_musl.Linux-x86_64.tar.zst"
|
||||
checksum = "b" * 64
|
||||
with patch.object(SDK, "urlopen", side_effect=[
|
||||
io.BytesIO(f'<a href="{filename}">SDK</a>'.encode()),
|
||||
io.BytesIO(f"{checksum} *{filename}\n".encode())
|
||||
]) as fetch:
|
||||
url, sha = SDK.resolve("SNAPSHOT", "x86/64")
|
||||
self.assertEqual(url, f"https://downloads.openwrt.org/snapshots/targets/x86/64/{filename}")
|
||||
self.assertEqual(sha, checksum)
|
||||
self.assertEqual(fetch.call_args_list[0].args[0],
|
||||
"https://downloads.openwrt.org/snapshots/targets/x86/64/")
|
||||
|
||||
def test_selected_configuration_preserves_target_and_disables_sdk_all_defaults(self):
|
||||
spec = importlib.util.spec_from_file_location("configure_sdk", ROOT / "tools/configure-sdk.py")
|
||||
configure = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(configure)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = pathlib.Path(tmp)
|
||||
(directory / "rules.mk").touch()
|
||||
(directory / "Makefile").touch()
|
||||
(directory / ".config").write_text("CONFIG_TARGET_x86=y\nCONFIG_ALL=y\nCONFIG_ALL_KMODS=y\nCONFIG_ALL_NONSHARED=y\nCONFIG_PACKAGE_unrelated=y\nCONFIG_LUCI_LANG_de=y\n")
|
||||
configure.configure(directory)
|
||||
result = (directory / ".config").read_text()
|
||||
self.assertIn("CONFIG_TARGET_x86=y", result)
|
||||
self.assertIn("# CONFIG_ALL_KMODS is not set", result)
|
||||
self.assertIn("CONFIG_PACKAGE_luci-app-device-manager=m", result)
|
||||
self.assertIn("CONFIG_PACKAGE_luci-i18n-device-manager-zh-cn=m", result)
|
||||
self.assertNotIn("CONFIG_PACKAGE_unrelated", result)
|
||||
self.assertNotIn("CONFIG_LUCI_LANG_de", result)
|
||||
configure.configure(directory)
|
||||
self.assertEqual((directory / ".config").read_text(), result)
|
||||
with self.assertRaises(ValueError):
|
||||
configure.configure(directory / "not-an-sdk")
|
||||
|
||||
def test_upstream_export_is_bounded_and_preserves_existing_checkout(self):
|
||||
spec = importlib.util.spec_from_file_location("prepare_upstream", ROOT / "tools/prepare-upstream.py")
|
||||
export = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(export)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
checkout = pathlib.Path(tmp)
|
||||
(checkout / "luci.mk").touch()
|
||||
target = export.prepare(checkout)
|
||||
self.assertTrue((target / "htdocs").is_dir())
|
||||
self.assertIn("include ../../luci.mk", (target / "Makefile").read_text())
|
||||
self.assertNotIn("feeds/luci/luci.mk", (target / "Makefile").read_text())
|
||||
self.assertTrue((target / "root/etc/config/device_manager").is_file())
|
||||
self.assertFalse((target / "root/etc/uci-defaults/80_device_manager").exists())
|
||||
readme = (target / "README.md").read_text()
|
||||
self.assertIn("opkg install luci-app-device-manager\n", readme)
|
||||
self.assertIn("apk add luci-app-device-manager\n", readme)
|
||||
for obsolete in ["尚未合并到官方源", "--allow-untrusted", "GitHub Actions", "workflow 默认"]:
|
||||
self.assertNotIn(obsolete, readme)
|
||||
for name in [".github", "tools", "test", "device-icons"]:
|
||||
self.assertFalse((target / name).exists())
|
||||
self.assertFalse((target / "docs/upstream-submission.md").exists())
|
||||
(target / "user-sentinel").write_text("preserve")
|
||||
with self.assertRaises(FileExistsError):
|
||||
export.prepare(checkout)
|
||||
self.assertEqual((target / "user-sentinel").read_text(), "preserve")
|
||||
|
||||
def test_scan_acl_requires_write_permission_but_keeps_passive_discovery_readable(self):
|
||||
acl = json.loads((ROOT / "root/usr/share/rpcd/acl.d/luci-app-device-manager.json").read_text())["luci-app-device-manager"]
|
||||
self.assertIn("get_online_status", acl["read"]["ubus"]["luci.device-manager"])
|
||||
self.assertNotIn("scan_devices", acl["read"]["ubus"]["luci.device-manager"])
|
||||
self.assertIn("scan_devices", acl["write"]["ubus"]["luci.device-manager"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
'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 } = require('node:child_process');
|
||||
const { root } = require('./helpers/runtime');
|
||||
|
||||
function temporary(fn) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-test-'));
|
||||
return Promise.resolve().then(() => fn(directory)).finally(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
}
|
||||
function rpc(command, prefix, directory, neighbors, exit = '0', method = 'get_online_status', language = 'auto', uciExit = '0') {
|
||||
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-probe', path.join(directory, 'probe'));
|
||||
const wrapper = 'ip() { [ "$1" = -4 ] && return 0; printf "%s\\n" "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; }\n' +
|
||||
'uci() { [ "$3" = network.lan.ipaddr ] && return 1; if [ "$AUDIT_UCI_EXIT" = 0 ]; then printf "%s\\n" "$AUDIT_LANGUAGE"; fi; return "$AUDIT_UCI_EXIT"; }\n' +
|
||||
'ping() { return 1; }\n' + source;
|
||||
return spawnSync(command, [...prefix, '-c', wrapper, 'luci.device-manager', 'call', method], { encoding: 'utf8', env: {
|
||||
...process.env, AUDIT_NEIGH: neighbors, AUDIT_IP_EXIT: exit, AUDIT_LANGUAGE: language, AUDIT_UCI_EXIT: uciExit,
|
||||
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.log')
|
||||
} });
|
||||
}
|
||||
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
|
||||
const available = spawnSync(command, [...prefix, '-c', 'exit 0']).status === 0;
|
||||
test(`${command}: RPC preserves MAC-less FAILED/INCOMPLETE and safely encodes strings`, { skip: !available }, () => temporary(directory => {
|
||||
const result = rpc(command, prefix, directory,
|
||||
'192.168.1.20 dev br-lan FAILED\n192.168.1.21 dev br-lan INCOMPLETE\nfe80::1 dev br-"lan lladdr aa:bb:cc:11:22:33 STALE proto kernel');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const reply = JSON.parse(result.stdout);
|
||||
assert.equal(reply.ok, true); assert.equal(reply.neighbors.length, 3);
|
||||
assert.equal(reply.neighbors[0].state, 'FAILED'); assert.equal(reply.neighbors[0].mac, '');
|
||||
assert.equal(reply.neighbors[1].state, 'INCOMPLETE');
|
||||
assert.equal(reply.neighbors[2].state, 'STALE'); assert.equal(reply.neighbors[2].dev, 'br-"lan');
|
||||
}));
|
||||
test(`${command}: RPC reports an unavailable ip command separately from an empty table`, { skip: !available }, () => temporary(directory => {
|
||||
const failed = rpc(command, prefix, directory, '', '127');
|
||||
assert.equal(JSON.parse(failed.stdout).ok, false);
|
||||
const empty = rpc(command, prefix, directory, '');
|
||||
assert.deepEqual(JSON.parse(empty.stdout), { ok: true, neighbors: [] });
|
||||
}));
|
||||
}
|
||||
function initialize(directory, content, failure = false) {
|
||||
const config = path.join(directory, 'config');
|
||||
if (content !== null) fs.writeFileSync(config, content);
|
||||
const source = fs.readFileSync(path.join(root, 'tools/initialize-config.sh'), 'utf8').replaceAll('/etc/config/device_manager', config);
|
||||
const wrapper = 'uci() { printf "%s\\n" "$*" >> "$AUDIT_UCI_LOG"; if [ "$2" = batch ]; then cat >> "$AUDIT_UCI_LOG"; fi; return "$AUDIT_UCI_EXIT"; }\n' + source;
|
||||
const result = spawnSync('busybox', ['sh', '-c', wrapper], { encoding: 'utf8', env: {
|
||||
...process.env, AUDIT_UCI_LOG: path.join(directory, 'uci.log'), AUDIT_UCI_EXIT: failure ? '1' : '0'
|
||||
} });
|
||||
return { result, config, log: path.join(directory, 'uci.log') };
|
||||
}
|
||||
test('initializer preserves populated and intentionally empty configurations', () => temporary(directory => {
|
||||
for (const content of ['', "config device 'saved'\n\toption name 'Existing TV'\n"]) {
|
||||
const { result, config, log } = initialize(directory, content);
|
||||
assert.equal(result.status, 0); assert.equal(fs.readFileSync(config, 'utf8'), content);
|
||||
assert.equal(fs.existsSync(log), false);
|
||||
}
|
||||
}));
|
||||
test('first installation creates default groups exactly once', () => temporary(directory => {
|
||||
const { result, config, log } = initialize(directory, null);
|
||||
assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(config), true);
|
||||
const first = fs.readFileSync(log, 'utf8'); assert.match(first, /smart_home/); assert.match(first, /commit device_manager/);
|
||||
initialize(directory, null); assert.equal(fs.readFileSync(log, 'utf8'), first);
|
||||
}));
|
||||
test('failed initialization removes the new incomplete configuration for retry', () => temporary(directory => {
|
||||
const { result, config } = initialize(directory, null, true);
|
||||
assert.notEqual(result.status, 0); assert.equal(fs.existsSync(config), false);
|
||||
}));
|
||||
function deploy(directory, args, failure = false) {
|
||||
const bin = path.join(directory, 'bin'); fs.mkdirSync(bin, { recursive: true });
|
||||
for (const command of ['ssh', 'scp']) {
|
||||
fs.writeFileSync(path.join(bin, command), '#!/bin/sh\nprintf "%s: %s\\n" "'+command+'" "$*" >> "$AUDIT_COMMAND_LOG"\nexit "$AUDIT_REMOTE_EXIT"\n', { mode: 0o755 });
|
||||
}
|
||||
return spawnSync('bash', [path.join(root, 'tools/deploy.sh'), ...args], { encoding: 'utf8', env: {
|
||||
...process.env, PATH: bin + ':' + process.env.PATH,
|
||||
AUDIT_COMMAND_LOG: path.join(directory, 'commands.log'), AUDIT_REMOTE_EXIT: failure ? '1' : '0'
|
||||
} });
|
||||
}
|
||||
test('deployment succeeds with mocked remote commands and copies all frontend modules', () => temporary(directory => {
|
||||
const result = deploy(directory, ['-p', '2222', 'audit.invalid']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8');
|
||||
assert.match(log, /resources\/device-manager /); assert.match(log, /view\/device-manager\/devices.js/);
|
||||
assert.match(log, /-P 2222/); assert.match(log, /rm -f \/etc\/uci-defaults\/80_device_manager/);
|
||||
assert.doesNotMatch(result.stderr, /EOF/);
|
||||
}));
|
||||
test('deployment rejects missing or invalid SSH ports before connecting', () => temporary(directory => {
|
||||
for (const args of [['-p'], ['-p', 'bad', 'audit.invalid'], ['-p', '0', 'audit.invalid'], ['-p', '65536', 'audit.invalid']]) {
|
||||
const result = deploy(directory, args);
|
||||
assert.notEqual(result.status, 0); assert.match(result.stderr, /SSH port must/);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(directory, 'commands.log')), false);
|
||||
}));
|
||||
test('failed SSH connectivity prevents deployment', () => temporary(directory => {
|
||||
const result = deploy(directory, ['audit.invalid'], true);
|
||||
assert.notEqual(result.status, 0); assert.match(result.stderr, /Failed to connect/);
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(directory, 'commands.log'), 'utf8'), /scp:/);
|
||||
}));
|
||||
|
||||
test('development deployment uses native translations and removes legacy bundled resources', () => temporary(directory => {
|
||||
const result = deploy(directory, ['audit.invalid']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8');
|
||||
assert.match(log, /rm -f .*device-manager-builtin.zh-cn.lmo/);
|
||||
assert.doesNotMatch(log, /scp:.*device-manager-builtin/);
|
||||
assert.match(log, /i18n.js.*translations.js/);
|
||||
}));
|
||||
@@ -0,0 +1,309 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { environment, loadModule, backend } = require('./helpers/runtime');
|
||||
|
||||
const model = loadModule('device-manager.model', environment());
|
||||
|
||||
test('model.sortDevices sorts IPv4 addresses numerically in ascending and descending orders', () => {
|
||||
const devices = [
|
||||
{ mac: '00:00:00:00:00:03', ipv4: '192.168.1.100', hostname: 'PC-100' },
|
||||
{ mac: '00:00:00:00:00:01', ipv4: '192.168.1.2', hostname: 'PC-2' },
|
||||
{ mac: '00:00:00:00:00:04', ipv4: '', hostname: 'No-IP' },
|
||||
{ mac: '00:00:00:00:00:02', ipv4: '192.168.1.10', hostname: 'PC-10' },
|
||||
{ mac: '00:00:00:00:00:05', ipv4: '10.0.0.1', hostname: 'Router' }
|
||||
];
|
||||
|
||||
// Ascending: 10.0.0.1 -> 192.168.1.2 -> 192.168.1.10 -> 192.168.1.100 -> No-IP
|
||||
const asc = model.sortDevices(devices, 'ip', 'asc');
|
||||
assert.deepEqual(asc.map(d => d.ipv4), [
|
||||
'10.0.0.1',
|
||||
'192.168.1.2',
|
||||
'192.168.1.10',
|
||||
'192.168.1.100',
|
||||
''
|
||||
]);
|
||||
|
||||
// Descending: 192.168.1.100 -> 192.168.1.10 -> 192.168.1.2 -> 10.0.0.1 -> No-IP (empty remains at bottom)
|
||||
const desc = model.sortDevices(devices, 'ip', 'desc');
|
||||
assert.deepEqual(desc.map(d => d.ipv4), [
|
||||
'192.168.1.100',
|
||||
'192.168.1.10',
|
||||
'192.168.1.2',
|
||||
'10.0.0.1',
|
||||
''
|
||||
]);
|
||||
});
|
||||
|
||||
test('model.sortDevices handles IPv6-only devices and tie-breakers', () => {
|
||||
const devices = [
|
||||
{ mac: '00:00:00:00:00:02', ipv4: '', ipv6: 'fd00::2' },
|
||||
{ mac: '00:00:00:00:00:01', ipv4: '', ipv6: 'fd00::1' },
|
||||
{ mac: '00:00:00:00:00:03', ipv4: '', ipv6: '' }
|
||||
];
|
||||
|
||||
const sorted = model.sortDevices(devices, 'ip', 'asc');
|
||||
assert.equal(sorted[0].ipv6, 'fd00::1');
|
||||
assert.equal(sorted[1].ipv6, 'fd00::2');
|
||||
assert.equal(sorted[2].ipv6, '');
|
||||
});
|
||||
|
||||
test('model.sortDevices sorts by name, MAC, group, and type', () => {
|
||||
const devices = [
|
||||
{ mac: '00:00:00:00:00:03', customName: 'Zebra', group: 'grp2', type: 'phone' },
|
||||
{ mac: '00:00:00:00:00:01', customName: 'Apple', group: 'grp1', type: 'laptop' },
|
||||
{ mac: '00:00:00:00:00:02', customName: 'Banana', group: 'grp3', type: 'tv' }
|
||||
];
|
||||
|
||||
const byName = model.sortDevices(devices, 'name', 'asc');
|
||||
assert.deepEqual(byName.map(d => d.customName), ['Apple', 'Banana', 'Zebra']);
|
||||
|
||||
const byMac = model.sortDevices(devices, 'mac', 'desc');
|
||||
assert.deepEqual(byMac.map(d => d.mac), ['00:00:00:00:00:03', '00:00:00:00:00:02', '00:00:00:00:00:01']);
|
||||
|
||||
const byGroup = model.sortDevices(devices, 'group', 'asc', id => id);
|
||||
assert.deepEqual(byGroup.map(d => d.group), ['grp1', 'grp2', 'grp3']);
|
||||
|
||||
const byType = model.sortDevices(devices, 'type', 'asc');
|
||||
assert.deepEqual(byType.map(d => d.type), ['laptop', 'phone', 'tv']);
|
||||
});
|
||||
|
||||
test('clicking the IP column header sorts devices in the table', async () => {
|
||||
const initial = [
|
||||
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-1' },
|
||||
{ '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: 'Dev-2' },
|
||||
{ '.type': 'device', '.name': 'dev_3', mac: '00:11:22:33:44:03', name: 'Dev-3' }
|
||||
];
|
||||
|
||||
const mocks = backend(initial);
|
||||
mocks.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [
|
||||
{ mac: '00:11:22:33:44:01', state: 'REACHABLE' },
|
||||
{ mac: '00:11:22:33:44:02', state: 'REACHABLE' },
|
||||
{ mac: '00:11:22:33:44:03', state: 'REACHABLE' }
|
||||
]
|
||||
};
|
||||
mocks.replies['luci-rpc.getHostHints'] = {
|
||||
'00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.100'] },
|
||||
'00:11:22:33:44:02': { name: 'host2', ipaddrs: ['192.168.1.5'] },
|
||||
'00:11:22:33:44:03': { name: 'host3', ipaddrs: ['192.168.1.20'] }
|
||||
};
|
||||
|
||||
const env = environment(mocks);
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
|
||||
const data = await page.load();
|
||||
const viewNode = page.render(data);
|
||||
|
||||
const table = viewNode.querySelector('#device_manager_table');
|
||||
const thList = table.querySelectorAll('th');
|
||||
assert.equal(thList.length, 8);
|
||||
assert.ok(thList[0].classes.has('dm-index-column'));
|
||||
const ipTh = thList.find(th => th.getAttribute('data-sort') === 'ip');
|
||||
assert.ok(ipTh);
|
||||
assert.equal(ipTh.classes.has('dm-sortable'), true);
|
||||
assert.equal(ipTh.querySelector('button').attrs.type, 'button');
|
||||
|
||||
// First click: Sort by IP ascending (192.168.1.5 -> 192.168.1.20 -> 192.168.1.100)
|
||||
await ipTh.querySelector('button').click();
|
||||
assert.equal(page.sortKey, 'ip');
|
||||
assert.equal(page.sortDir, 'asc');
|
||||
assert.equal(ipTh.classes.has('sorted-asc'), true);
|
||||
assert.equal(ipTh.getAttribute('aria-sort'), 'ascending');
|
||||
|
||||
let tbody = viewNode.querySelector('#device_manager_tbody');
|
||||
let rows = tbody.querySelectorAll('tr');
|
||||
assert.equal(rows.length, 3);
|
||||
assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']);
|
||||
// 4th td in each row is IP address
|
||||
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
|
||||
assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20'));
|
||||
assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.100'));
|
||||
|
||||
// Second click: Sort by IP descending (192.168.1.100 -> 192.168.1.20 -> 192.168.1.5)
|
||||
await ipTh.querySelector('button').click();
|
||||
assert.equal(page.sortKey, 'ip');
|
||||
assert.equal(page.sortDir, 'desc');
|
||||
assert.equal(ipTh.classes.has('sorted-desc'), true);
|
||||
assert.equal(ipTh.getAttribute('aria-sort'), 'descending');
|
||||
|
||||
tbody = viewNode.querySelector('#device_manager_tbody');
|
||||
rows = tbody.querySelectorAll('tr');
|
||||
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.100'));
|
||||
assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20'));
|
||||
assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
|
||||
assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']);
|
||||
|
||||
// Click Name header: switch sorting to Name
|
||||
const nameTh = thList.find(th => th.getAttribute('data-sort') === 'name');
|
||||
assert.ok(nameTh);
|
||||
await nameTh.querySelector('button').click();
|
||||
assert.equal(page.sortKey, 'name');
|
||||
assert.equal(page.sortDir, 'asc');
|
||||
assert.equal(ipTh.classes.has('sorted-asc'), false);
|
||||
assert.equal(ipTh.classes.has('sorted-desc'), false);
|
||||
assert.equal(ipTh.getAttribute('aria-sort'), 'none');
|
||||
assert.equal(nameTh.classes.has('sorted-asc'), true);
|
||||
assert.equal(nameTh.getAttribute('aria-sort'), 'ascending');
|
||||
|
||||
page.filterText = 'host2';
|
||||
page.updateView();
|
||||
rows = tbody.querySelectorAll('tr');
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].querySelector('.dm-index-column').textContent, '1');
|
||||
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
|
||||
page.filterText = 'no matching device';
|
||||
page.updateView();
|
||||
assert.equal(tbody.querySelector('td').getAttribute('colspan'), thList.length);
|
||||
});
|
||||
|
||||
test('status indicator renders as a small dot without text, and default tab is Online followed by All', async () => {
|
||||
const initial = [
|
||||
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-Online' }
|
||||
];
|
||||
|
||||
const mocks = backend(initial);
|
||||
mocks.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }]
|
||||
};
|
||||
mocks.replies['luci-rpc.getHostHints'] = {
|
||||
'00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.10'] }
|
||||
};
|
||||
|
||||
const env = environment(mocks);
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
|
||||
const data = await page.load();
|
||||
const viewNode = page.render(data);
|
||||
|
||||
// Check tabs order: 0 is online, 1 is all
|
||||
const tabs = viewNode.querySelector('#dm-tabs-container').children;
|
||||
assert.equal(tabs[0].getAttribute('data-tab'), 'online');
|
||||
assert.equal(tabs[1].getAttribute('data-tab'), 'all');
|
||||
assert.equal(page.activeTab, 'online');
|
||||
assert.equal(tabs[0].classes.has('active'), true);
|
||||
|
||||
// Check row has dot and no badge text
|
||||
const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
|
||||
const dot = row.querySelector('.dm-status-dot');
|
||||
assert.ok(dot);
|
||||
assert.equal(dot.classes.has('dm-status-dot-online'), true);
|
||||
assert.equal(dot.textContent, ''); // No text displayed inside the dot!
|
||||
assert.ok(dot.attrs.title.includes('Online') || dot.attrs.title.includes('REACHABLE'));
|
||||
});
|
||||
|
||||
test('table row display omits no-hostname subtitle, prefix from hostname subtitle, and leaves ungrouped and remark blank', async () => {
|
||||
const initial = [
|
||||
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'My-Server', group: 'ungrouped', remark: '' },
|
||||
{ '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: '', group: 'ungrouped', remark: '' }
|
||||
];
|
||||
const mocks = backend(initial);
|
||||
mocks.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [
|
||||
{ mac: '00:11:22:33:44:01', state: 'REACHABLE' },
|
||||
{ mac: '00:11:22:33:44:02', state: 'REACHABLE' }
|
||||
]
|
||||
};
|
||||
mocks.replies['luci-rpc.getHostHints'] = {
|
||||
'00:11:22:33:44:01': { name: 'NAS', ipaddrs: ['192.168.1.10'] },
|
||||
'00:11:22:33:44:02': { name: '', ipaddrs: ['192.168.1.11'] }
|
||||
};
|
||||
|
||||
const env = environment(mocks);
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
|
||||
const data = await page.load();
|
||||
const viewNode = page.render(data);
|
||||
|
||||
const rows = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr');
|
||||
assert.equal(rows.length, 2);
|
||||
|
||||
// dev_1 has customName 'My-Server' and hostname 'NAS'
|
||||
const row1 = rows[0];
|
||||
const row1Tds = row1.querySelectorAll('td');
|
||||
const titleRow1 = row1Tds[2].querySelector('.dm-device-title-row');
|
||||
assert.ok(titleRow1.textContent.includes('My-Server'));
|
||||
const subtitle1 = row1Tds[2].querySelector('.dm-device-subtitle');
|
||||
assert.ok(subtitle1);
|
||||
assert.equal(subtitle1.textContent, 'NAS'); // Only NAS, no "Hostname: " or "主机名: " prefix
|
||||
assert.equal(row1Tds[5].textContent, '');
|
||||
assert.equal(row1Tds[6].textContent, '');
|
||||
|
||||
// dev_2 has no customName and no hostname
|
||||
const row2 = rows[1];
|
||||
const row2Tds = row2.querySelectorAll('td');
|
||||
const subtitle2 = row2Tds[2].querySelector('.dm-device-subtitle');
|
||||
assert.equal(subtitle2, null); // No "No hostname detected" or "无检测主机名"
|
||||
assert.equal(row2Tds[5].textContent, '');
|
||||
assert.equal(row2Tds[6].textContent, '');
|
||||
});
|
||||
|
||||
test('mask/show info button toggles MAC and IPv6 masking with asterisks and remembers preference', async () => {
|
||||
let stored = null;
|
||||
const initial = [
|
||||
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'PC' }
|
||||
];
|
||||
const mocks = backend(initial);
|
||||
mocks.replies['luci.device-manager.get_online_status'] = {
|
||||
ok: true,
|
||||
neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }]
|
||||
};
|
||||
mocks.replies['luci-rpc.getHostHints'] = {
|
||||
'00:11:22:33:44:01': { name: 'PC', ipaddrs: ['192.168.1.10'], ip6addrs: ['fe80::1234:5678:9abc:def0'] }
|
||||
};
|
||||
|
||||
const env = environment(Object.assign({}, mocks, {
|
||||
window: {
|
||||
localStorage: {
|
||||
getItem: () => stored,
|
||||
setItem: (k, v) => { stored = v; }
|
||||
}
|
||||
}
|
||||
}));
|
||||
const page = loadModule('view.device-manager.devices', env);
|
||||
|
||||
const data = await page.load();
|
||||
const viewNode = page.render(data);
|
||||
|
||||
// By default maskInfo is false: MAC and IPv6 are unmasked
|
||||
let row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
|
||||
let rowTds = row.querySelectorAll('td');
|
||||
assert.ok(rowTds[3].textContent.includes('192.168.1.10')); // IPv4
|
||||
assert.ok(rowTds[3].textContent.includes('fe80::1234:5678:9abc:def0')); // IPv6 unmasked
|
||||
assert.ok(rowTds[4].textContent.includes('00:11:22:33:44:01')); // MAC unmasked
|
||||
|
||||
// Click mask toggle button
|
||||
const maskBtn = viewNode.querySelector('#dm-btn-mask');
|
||||
assert.ok(maskBtn);
|
||||
await maskBtn.click();
|
||||
|
||||
// After clicking, maskInfo is true
|
||||
assert.equal(page.maskInfo, true);
|
||||
assert.deepEqual(JSON.parse(stored), { tab: 'online', group: 'all', maskInfo: true });
|
||||
|
||||
row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
|
||||
rowTds = row.querySelectorAll('td');
|
||||
// IPv4 remains completely unchanged
|
||||
assert.ok(rowTds[3].textContent.includes('192.168.1.10'));
|
||||
// IPv6 is masked with asterisks
|
||||
assert.ok(rowTds[3].textContent.includes('****::****:****:****:****'));
|
||||
assert.ok(!rowTds[3].textContent.includes('fe80'));
|
||||
// MAC is masked with asterisks
|
||||
assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**'));
|
||||
assert.ok(!rowTds[4].textContent.includes('00:11:22'));
|
||||
|
||||
// Test persistence on page reload
|
||||
const pageReload = loadModule('view.device-manager.devices', env);
|
||||
const reloadedData = await pageReload.load();
|
||||
const reloadedView = pageReload.render(reloadedData);
|
||||
assert.equal(pageReload.maskInfo, true);
|
||||
|
||||
row = reloadedView.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
|
||||
rowTds = row.querySelectorAll('td');
|
||||
assert.ok(rowTds[3].textContent.includes('192.168.1.10'));
|
||||
assert.ok(rowTds[3].textContent.includes('****::****:****:****:****'));
|
||||
assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**'));
|
||||
});
|
||||
Reference in new issue
Block a user