⛅ Sync 2026-10-11 13:16:00

This commit is contained in:
github-actions[bot] committed 2026-10-11 13:16:00 +08:00
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');
});
+98
View File
@@ -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/);
});
+105
View File
@@ -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');
});
}
+209
View File
@@ -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), []);
});
}
+141
View File
@@ -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()
+111
View File
@@ -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('**:**:**:**:**:**'));
});