'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); const { tmpdir } = require('node:os'); const test = require('node:test'); const root = path.join(__dirname, '..'); const menuSource = fs.readFileSync(path.join(root, 'htdocs/luci-static/resources/menu-argon.js'), 'utf8'); function element(tag, attrs = {}, children = []) { const node = { tag, attrs, children: [], style: {}, appendChild(child) { if (child && child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1); this.children.push(child); if (child && typeof child === 'object') child.parent = this; } }; for (const child of children) node.appendChild(child); return node; } String.prototype.format = function(...values) { let i = 0; return this.replace(/%s/g, () => values[i++]); }; function loadMenu(dispatchpath, container, icons = '') { return new Function('baseclass', 'ui', 'L', 'E', 'document', '_', menuSource)( { extend: value => value }, { menu: { getChildren: node => node.children || [] }, createHandlerFn: (object, name) => object[name].bind(object) }, { env: { dispatchpath }, url: (...parts) => parts.join('/'), media: (...parts) => '/luci-static/argon/' + parts.join('/') }, element, { querySelectorAll: () => [], querySelector: selector => selector == 'meta[name="argon-menu-icons"]' ? (icons === null ? null : { getAttribute: () => icons }) : container }, value => value ); } test('menu icons use exact node names and fall back without requesting missing files', () => { const menu = loadMenu([], null, 'default.svg status.svg system.svg'); for (const name of ['status', 'system']) { const icon = menu.renderMenuIcon(name); assert.equal(icon.attrs['data-icon'], name); assert.equal(icon.attrs['aria-hidden'], 'true'); assert.match(icon.attrs.style, new RegExp('/icon/' + name + '\\.svg')); } for (const name of ['Status', '状态', 'missing', '../status', 'status.svg', 'status";color:red']) { const icon = menu.renderMenuIcon(name); assert.equal(icon.attrs['data-icon'], 'default'); assert.match(icon.attrs.style, /\/icon\/default\.svg/); } assert.equal(loadMenu([], null, null).renderMenuIcon('status').attrs['data-icon'], 'default'); }); test('translated menu labels keep node-name icons and nested links keep their layout', () => { const container = element('div'); const menu = loadMenu([], container, 'default.svg status.svg'); const rendered = menu.renderMainMenu({ name: 'admin', children: [{ name: 'status', title: '状态', children: [{ name: 'overview', title: '概览' }] }] }, 'admin'); const link = rendered.children[0].children[0]; assert.equal(link.children[0].attrs['data-icon'], 'status'); assert.equal(link.children[1], '状态'); assert.equal(rendered.children[0].children[1].children[0].children[0].children[0], '概览'); }); test('clicking a decorative SVG is handled by its navigation link', () => { const menu = loadMenu([], null); const link = { parentNode: { querySelector: () => null }, nextElementSibling: {}, blur() {} }; const icon = { parentNode: link, nextElementSibling: null }; let prevented = false, stopped = false; menu.handleMenuExpand({ target: icon, currentTarget: link, preventDefault: () => { prevented = true; }, stopPropagation: () => { stopped = true; } }); assert.equal(prevented, true); assert.equal(stopped, true); }); test('nested LuCI tabs stay in parent-to-child order', () => { const container = element('div'); const menu = loadMenu(['admin', 'system', 'demo', 'a', 'b', 'c', 'd'], container); const tree = { children: [{ name: 'a', title: 'A', children: [ { name: 'b', title: 'B', children: [ { name: 'c', title: 'C', children: [ { name: 'd', title: 'D', children: [] } ] } ] } ] }] }; menu.renderTabMenu(tree, 'admin/system/demo'); assert.deepEqual(container.children.map(ul => ul.children[0].children[0].children[0]), ['A', 'B', 'C', 'D']); assert.equal(container.children[0].children[0].attrs.class, 'tabmenu-item-a active'); assert.equal(container.children[3].children[0].children[0].attrs.href, 'admin/system/demo/a/b/c/d'); }); function runUcodeSnippet(file, start, end, setup, result, settings = {}, hasConfig = true) { const source = fs.readFileSync(path.join(root, file), 'utf8'); const first = source.indexOf(start); const last = source.indexOf(end, first); assert.ok(first >= 0 && last > first, `Cannot find Ucode snippet in ${file}`); const dir = mkdtempSync(path.join(tmpdir(), 'argon-ucode-test-')); const script = path.join(dir, 'test.uc'); writeFileSync(script, `${setup}\n${source.slice(first, last)}\nprint(sprintf("%J", ${result}));\n`); try { const output = execFileSync(process.env.UCODE_BIN || 'ucode', [ '-L', path.join(path.dirname(process.env.UCODE_BIN || 'ucode'), '*.so'), '-L', path.join(root, 'root/usr/share/ucode/*.uc'), `-Dsettings=${JSON.stringify(settings)}`, `-DhasConfig=${JSON.stringify(hasConfig)}`, script ], { encoding: 'utf8' }); return JSON.parse(output); } finally { rmSync(dir, { recursive: true, force: true }); } } const configSetup = ` import { dark_palette, appearance_modes } from 'luci.argon-palette'; let cfg = { get_first: (packageName, section, key) => settings[key] }; function access(path) { return hasConfig; } `; test('the SVG catalog includes only safe regular SVG files and tolerates a missing directory', () => { const setup = ` function lsdir(path) { return settings.files; } function lstat(path) { let parts = split(path, '/'); return { type: settings.types?.[parts[length(parts) - 1]] ?? 'file' }; } `; const catalog = settings => runUcodeSnippet('ucode/template/themes/argon/head.ut', 'const icon_dir =', '\n\t%}', setup, 'menu_icons', settings); assert.deepEqual(catalog({ files: ['status.svg', 'custom-menu.svg', 'favicon.png', '../system.svg', 'bad name.svg', 'bad".svg', 'folder.svg', 'linked.svg'], types: { 'folder.svg': 'directory', 'linked.svg': 'symlink' } }), ['status.svg', 'custom-menu.svg']); assert.deepEqual(catalog({ files: null }), []); }); const configResult = '{mode, primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark, bar_color}'; test('shared head uses safe defaults for missing and invalid settings', () => { const empty = runUcodeSnippet('ucode/template/themes/argon/head.ut', 'function validColor', '\n-%}', configSetup, configResult); assert.deepEqual(empty, { mode: 'normal', primary: '#5e72e4', dark_primary: '#5a6cc5', blur_radius: '10', blur_radius_dark: '10', blur_opacity: '0.5', blur_opacity_dark: '0.5', bar_color: '#5e72e4' }); assert.deepEqual(runUcodeSnippet('ucode/template/themes/argon/head.ut', 'function validColor', '\n-%}', configSetup, configResult, {}, false), empty); const invalid = runUcodeSnippet('ucode/template/themes/argon/head.ut', 'function validColor', '\n-%}', configSetup, configResult, { primary: 'red;}', blur: '-1', transparency: '2', mode: 'unknown' }); assert.deepEqual(invalid, empty); const dark = runUcodeSnippet('ucode/template/themes/argon/head.ut', 'function validColor', '\n-%}', configSetup, configResult, { primary: '#123456', dark_primary: '#abcdef', blur: '0', transparency: '0.7', mode: 'dark' }); assert.equal(dark.mode, 'dark'); assert.equal(dark.bar_color, '#abcdef'); assert.equal(dark.blur_radius, '0'); assert.equal(dark.blur_opacity, '0.7'); const light = runUcodeSnippet('ucode/template/themes/argon/head.ut', 'function validColor', '\n-%}', configSetup, configResult, { primary: '#123456', mode: 'light' }); assert.equal(light.mode, 'light'); assert.equal(light.bar_color, '#123456'); }); test('login layouts allow supported styles and fall back for old or invalid configs', () => { const selected = (value, hasConfig = true) => runUcodeSnippet( 'ucode/template/themes/argon/sysauth.ut', "let login_style = 'classic';", '\n\tconst boardinfo', configSetup + '\nlet fs = { access };', 'login_style', { login_style: value }, hasConfig ); assert.equal(selected('classic'), 'classic'); assert.equal(selected('centered'), 'centered'); assert.equal(selected('centered', false), 'classic'); for (const value of [undefined, null, '', 'unknown', 'CENTERED', '" onclick="alert(1)', '../centered', 1, ['centered'], {}]) assert.equal(selected(value), 'classic'); }); test('local backgrounds accept dotted names and encode spaces', () => { const setup = ` let fs = { lsdir: path => ['photo.jpg', 'my.photo.jpg', 'space name.webp', 'hash#name.jpg', 'what?.png', 'VIDEO.MP4', 'notes.txt'] }; let http = { urlencode: value => replace(replace(replace(value, ' ', '%20'), '#', '%23'), '?', '%3F') }; `; const backgrounds = runUcodeSnippet( 'ucode/template/themes/argon/login_background.ut', 'const imageTypes', '\n\tfunction selectBackground', setup, 'fetchMedia("/www/luci-static/argon/background", "/luci-static/argon/background/")' ); assert.deepEqual(backgrounds.map(bg => bg.url), [ '/luci-static/argon/background/photo.jpg', '/luci-static/argon/background/my.photo.jpg', '/luci-static/argon/background/space%20name.webp', '/luci-static/argon/background/hash%23name.jpg', '/luci-static/argon/background/what%3F.png', '/luci-static/argon/background/VIDEO.MP4' ]); assert.deepEqual(backgrounds.map(bg => bg.type), ['jpg', 'jpg', 'webp', 'jpg', 'png', 'mp4']); }); test('online wallpaper URLs require HTTPS and fall back locally', () => { const setup = ` let media = "/luci-static/argon"; let fs = { access: path => true }; let cfg = { get_first: (packageName, section, key) => key == 'online_wallpaper' ? 'bing' : null }; let ubus = { call: (object, method) => ({ url: settings.url }) }; let videoTypes = " mp4 webm "; function fetchMedia() { if (settings.empty) return []; if (settings.video) return [{ type: 'mp4', url: '/luci-static/argon/background/local.mp4' }]; return [{ type: 'jpg', url: '/luci-static/argon/background/local.jpg' }]; } function randomIndex() { return 0; } `; const file = 'ucode/template/themes/argon/login_background.ut'; const start = 'function selectBackground'; const end = '\n\tconst bgArry'; const selected = settings => runUcodeSnippet(file, start, end, setup, 'selectBackground("/luci-static/argon/background/")', settings); assert.equal(selected({ url: 'https://example.com/image.jpg' }).bgUrl, 'https://example.com/image.jpg'); for (const invalid of ['http://example.com/image.jpg', 'https://example.com/a b.jpg', 'javascript:alert(1)', 'https://example.com/"bad']) assert.equal(selected({ url: invalid }).bgUrl, '/luci-static/argon/background/local.jpg'); assert.equal(selected({ url: 'http://example.com/image.jpg', empty: true }).bgUrl, '/luci-static/argon/img/bg1.webp'); assert.deepEqual(selected({ url: '', video: true }), { bgUrl: '/luci-static/argon/background/local.mp4', backgroundType: 'Video', mimeType: 'video/mp4' }); }); test('authentication fields preserve numeric length limits and escape attributes', () => { const source = fs.readFileSync(path.join(root, 'ucode/template/themes/argon/login_form.ut'), 'utf8'); const first = source.indexOf('{%- if (auth_fields): %}'); const last = source.indexOf('{%- if (auth_html): %}', first); assert.ok(first >= 0 && last > first); const dir = mkdtempSync(path.join(tmpdir(), 'argon-auth-test-')); const script = path.join(dir, 'auth.ut'); // LuCI's html module returns null for non-string values, including numbers. const setup = `{% function _(value) { return value; } function entityencode(value, attribute) { if (type(value) != 'string') return null; return replace(replace(replace(replace(value, '&', '&'), '<', '<'), '>', '>'), '"', '"'); } %}`; try { writeFileSync(script, setup + source.slice(first, last)); const output = execFileSync(process.env.UCODE_BIN || 'ucode', [ '-T,', `-Dauth_fields=${JSON.stringify([{ name: 'luci_otp', type: 'text', label: 'One-Time Password', placeholder: 'Code "123456"', inputmode: 'numeric', pattern: '[0-9]*', maxlength: 6, autocomplete: 'one-time-code', required: true }])}`, script ], { encoding: 'utf8' }); assert.match(output, /maxlength="6"/); assert.match(output, /for="luci_otp">One-Time Password<\/label>/); assert.match(output, /title="Code "123456""/); assert.match(output, /autocomplete="one-time-code"/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('Unsplash falls back on missing key or failed fetch', () => { const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.argon_wallpaper'), 'utf8'); const imports = '. /lib/functions.sh\n. /usr/share/libubox/jshn.sh'; assert.ok(source.includes(imports)); const stub = ` uci() { case "$*" in *online_wallpaper) printf '%s\\n' "$TEST_WALLPAPER" ;; *use_api_key) [ -n "$TEST_KEY" ] && printf '%s\\n' "$TEST_KEY" ;; *use_exact_resolution) printf '1\\n' ;; esac } json_init() { json_url=''; } json_add_string() { [ "$1" = url ] && json_url="$2"; } json_dump() { printf '{"url":"%s"}\\n' "$json_url"; } json_cleanup() { :; } flock() { return 0; } wget() { return 1; } jsonfilter() { return 1; } `; const dir = mkdtempSync(path.join(tmpdir(), 'argon-wallpaper-test-')); try { const script = path.join(dir, 'wallpaper.sh'); const cache = path.join(dir, 'wallpaper.url'); writeFileSync(cache, 'https://example.com/old.jpg\n'); writeFileSync(script, source.replace(imports, stub) .replace('CACHE="/var/run/argon_${WEB_PIC_SRC}.url"', 'CACHE="$TEST_CACHE"') .replace('WRLOCK="/var/lock/argon_${WEB_PIC_SRC}.lock"', 'WRLOCK="$TEST_LOCK"')); for (const wallpaper of ['unsplash', 'unsplash_123']) { const output = execFileSync('sh', [script, 'call', 'get_url'], { input: '{}\n', encoding: 'utf8', env: { ...process.env, TEST_WALLPAPER: wallpaper, TEST_KEY: '', TEST_CACHE: cache, TEST_LOCK: path.join(dir, 'lock') } }); assert.deepEqual(JSON.parse(output), { url: '' }); } rmSync(cache); const failedFetch = execFileSync('bash', [script, 'call', 'get_url'], { input: '{}\n', encoding: 'utf8', env: { ...process.env, TEST_WALLPAPER: 'unsplash', TEST_KEY: 'test-key', TEST_CACHE: cache, TEST_LOCK: path.join(dir, 'lock') } }); assert.deepEqual(JSON.parse(failedFetch), { url: '' }); assert.equal(fs.readFileSync(cache, 'utf8'), ''); } finally { rmSync(dir, { recursive: true, force: true }); } }); // Exercise the real filesystem module, including malformed state and stale files. test('custom branding accepts only published assets and safely falls back', () => { const dir = mkdtempSync(path.join(tmpdir(), 'argon-branding-test-')); const assets = path.join(dir, 'branding'); const module = path.join(dir, 'luci', 'argon-branding.uc'); fs.mkdirSync(path.dirname(module)); const source = fs.readFileSync(path.join(root, 'root/usr/share/ucode/luci/argon-branding.uc'), 'utf8'); writeFileSync(module, source.replace('/www/luci-static/argon/branding/', assets + '/')); const ucode = process.env.UCODE_BIN || 'ucode'; const args = ['-L', path.join(path.dirname(ucode), '*.so'), '-L', path.join(dir, '*.uc')]; const read = () => JSON.parse(execFileSync(ucode, [...args, '-e', "import { get_branding } from 'luci.argon-branding'; print(sprintf('%J', get_branding()));" ], { encoding: 'utf8' })); const image = 'image-' + 'a'.repeat(64) + '.png'; const manifest = image.replace('.png', '.webmanifest'); const index = path.join(assets, 'current.json'); try { assert.deepEqual(read(), {}); fs.mkdirSync(assets); writeFileSync(path.join(assets, image), Buffer.alloc(100)); writeFileSync(path.join(assets, manifest), '{}'); writeFileSync(index, JSON.stringify({ favicon: image, logo: image })); assert.deepEqual(read(), { favicon: image, logo: image, manifest }); for (const bad of ['broken', 'null', '[]', '123', '"text"', '{"logo":"../elsewhere.png"}', ' '.repeat(1025)]) { writeFileSync(index, bad); assert.deepEqual(read(), {}); } writeFileSync(index, JSON.stringify({ logo: image, favicon: '../outside.png' })); assert.deepEqual(read(), { logo: image }); fs.unlinkSync(path.join(assets, image)); assert.deepEqual(read(), {}); writeFileSync(path.join(dir, 'outside.png'), Buffer.alloc(100)); fs.symlinkSync(path.join(dir, 'outside.png'), path.join(assets, image)); assert.deepEqual(read(), {}); fs.unlinkSync(index); fs.symlinkSync(path.join(dir, 'outside.png'), index); assert.deepEqual(read(), {}); fs.renameSync(assets, path.join(dir, 'saved')); fs.symlinkSync(path.join(dir, 'saved'), assets); assert.deepEqual(read(), {}); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('custom head icons replace every default candidate and restore as a group', () => { const dir = mkdtempSync(path.join(tmpdir(), 'argon-head-test-')); const source = fs.readFileSync(path.join(root, 'ucode/template/themes/argon/head_meta.ut'), 'utf8') .replace("import { get_branding } from 'luci.argon-branding';", 'function get_branding() { return fixture; }'); const file = path.join(dir, 'head.ut'); writeFileSync(file, '{% let media="/luci-static/argon"; let hostname="Router"; let node={}; let bar_color="#123456"; %}' + source); const render = fixture => execFileSync(process.env.UCODE_BIN || 'ucode', ['-T,', '-Dfixture=' + JSON.stringify(fixture), file], { encoding: 'utf8' }); try { const defaults = render({}); assert.match(defaults, /favicon\.ico/); assert.match(defaults, /apple-icon-144x144\.png/); assert.match(defaults, /icon\/manifest\.json/); const custom = render({ favicon: 'custom.png', manifest: 'custom.webmanifest' }); assert.match(custom, /rel="icon" type="image\/png" href="\/luci-static\/argon\/branding\/custom.png"/); assert.match(custom, /rel="apple-touch-icon" href="\/luci-static\/argon\/branding\/custom.png"/); assert.match(custom, /branding\/custom.webmanifest/); assert.doesNotMatch(custom, /favicon\.ico|icon\/favicon|icon\/android|icon\/apple|icon\/manifest/); assert.equal(render({ logo: 'custom.png' }), defaults); } finally { rmSync(dir, { recursive: true, force: true }); } });