💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

+653
View File
@@ -0,0 +1,653 @@
#!/usr/bin/env node
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 VolanticSystems
//
// appflow browser-half suite.
//
// WHY THIS FILE EXISTS. Every defect this package's frontend has had was found
// by a human or a review panel READING the code. Not one was found by a test,
// because until now no test could run any of it. The daemon has 54 checks
// against it and the JavaScript had none, which is backwards: the daemon
// mostly counts bytes, while the frontend is what decides whether an operator
// is told the collector is down.
//
// usage: node tests/frontend-suite.js
//
// Runs anywhere Node runs. No router, no browser, no network. The real
// common.js is loaded and executed by tests/luci-module.js; nothing in it is
// reimplemented here.
//
// EVERY CHECK IN THE FIRST THREE GROUPS IS A REGRESSION TEST FOR A DEFECT THAT
// WAS REAL, shipped, and fixed. They are not hypotheticals: each one names the
// date it was found and what it did to the screen.
//
// SABOTAGE COMMENTS: every check names the smallest edit to the PRODUCT that
// turns it red, written before the assertion.
'use strict';
const path = require('path');
const { load, potMsgids, textOf, classesOf } = require('./luci-module.js');
const ROOT = path.join(__dirname, '..');
const COMMON = path.join(ROOT, 'htdocs/luci-static/resources/view/appflow/common.js');
const POT = path.join(ROOT, 'po/templates/appflow.pot');
const DAEMON = path.join(ROOT, 'root/usr/sbin/appflowd');
// The category column of the daemon's AI_HOSTS table, read out of the daemon
// source itself. This is the only cross-file contract the browser half has: if
// the daemon emits a category the frontend has no key for, the label is
// untranslatable and nothing else in either suite notices.
//
// Deliberately a text scan rather than a fixture. A fixture would record what
// the daemon USED to send, which is exactly the failure being guarded against.
function daemonAiCategories() {
const src = require('fs').readFileSync(DAEMON, 'utf8');
const out = new Set();
const re = /\[\s*"[^"]*"\s*,\s*"(ai-[a-z-]+|AI [a-z]+)"\s*,/g;
let m;
while ((m = re.exec(src)) !== null) out.add(m[1]);
return [...out].sort();
}
let PASS = 0, FAIL = 0;
function ok(m) { PASS++; console.log(' PASS ' + m); }
function bad(m) { FAIL++; console.log(' FAIL ' + m); }
function chk(desc, expected, actual) {
const e = JSON.stringify(expected), a = JSON.stringify(actual);
if (e === a) ok(desc); else bad(`${desc} (expected ${e}, got ${a})`);
}
function head(m) { console.log('\n=== ' + m + ' ==='); }
// rpc and request throw if touched. Nothing under test calls them, and a stub
// that answered plausibly would let a test pass against a product that had
// stopped asking.
const c = load(COMMON, {
rpc: { declare: (o) => () => { throw new Error('rpc called: ' + (o && o.method)); } },
request: { get: () => { throw new Error('request called'); } }
});
// --------------------------------------------------- the status strip
head('STATUS STRIP: absent evidence is not positive evidence');
// THE DEFECT, found 2026-08-25. The test was
// connected = (s.agent_connected !== false) && (sock.connected !== false)
// and `undefined !== false` is true, so a payload carrying neither field
// rendered a single green "Netify agent connected" pill. Overview passes
// `status || summary`, so this fired exactly when the status call FAILED: the
// one indicator whose job is to say the collector is down failed GREEN, on the
// most common reason for the page being empty.
//
// SABOTAGE for this whole group: in statusStrip(), drop the `known &&` from
// the `connected` expression, or replace `known` with `true`. The three
// unknown rows go red and the two positive rows stay green, which is the
// asymmetry that matters.
const STATES = [
['no status at all', undefined, 'af-unknown'],
['an empty object', {}, 'af-unknown'],
// The payload that actually triggered it: a summary, passed in because the
// status call failed. It has neither field.
['a summary payload (the real case)', { bytes_total: 12345, top_apps: [] }, 'af-unknown'],
['agent_connected true', { agent_connected: true }, 'af-ok'],
['agent_connected false', { agent_connected: false }, 'af-warn'],
// socket.connected alone IS positive evidence: it says the daemon has the
// export socket open. `known` requires only that ONE of the two fields be
// present, which is deliberate.
['socket.connected true only', { socket: { connected: true } }, 'af-ok'],
['socket.connected false only', { socket: { connected: false } }, 'af-warn']
];
for (const [label, status, wantClass] of STATES) {
const cls = classesOf(c.statusStrip(status));
if (cls.has(wantClass)) ok(`${label} -> ${wantClass}`);
else bad(`${label} -> expected ${wantClass}, got [${[...cls].join(' ')}]`);
}
// And the text, because a neutral class with reassuring words is still a lie.
//
// SABOTAGE: change the unknown-state string back to "Netify agent connected".
// The class check above stays green and only this goes red.
const unknownText = textOf(c.statusStrip({ bytes_total: 1 }));
if (/unknown/i.test(unknownText)) ok('and the unknown state SAYS unknown');
else bad(`the unknown state reads "${unknownText}"`);
// -------------------------------------------- prototype-chain lookups
head('HOSTILE KEYS: a name from the wire must not reach Object.prototype');
// THE DEFECT, found 2026-08-25. CATEGORY_SLOT is an object literal, so it
// inherits Object.prototype, and a bare `CATEGORY_SLOT[s] != null` test passed
// for 'constructor', '__proto__', 'toString' and friends, indexing PALETTE
// with a function and yielding undefined. A device named `constructor`
// rendered style="background:undefined", which the browser drops: white text
// on a transparent tile, and in the doughnut the slice silently did not draw
// while its legend still claimed a percentage.
//
// `s` is attacker-influenced. It comes from a category tag, an application
// label, or a DHCP hostname, so a LAN device can choose it.
//
// SABOTAGE: in color(), replace
// Object.prototype.hasOwnProperty.call(CATEGORY_SLOT, s)
// with a bare `CATEGORY_SLOT[s] != null`. Every row below goes red.
const HOSTILE = ['constructor', '__proto__', 'toString', 'hasOwnProperty',
'valueOf', 'isPrototypeOf'];
for (const key of HOSTILE) {
const col = c.color(key);
if (typeof col === 'string' && /^hsl\(/.test(col))
ok(`color(${JSON.stringify(key)}) is a real colour`);
else
bad(`color(${JSON.stringify(key)}) returned ${JSON.stringify(col)}`);
}
// The mirror: a function that returned a fixed colour for everything would
// pass all six rows above. A known category must still get its ASSIGNED slot,
// not the hash fallback.
//
// SABOTAGE: delete the hasOwnProperty branch entirely so everything falls
// through to the hash. This goes red while the six rows above stay green.
const known = c.color('games'), other = c.color('games');
chk('a known category is stable across calls', known, other);
if (c.color('games') !== c.color('shopping'))
ok('and different categories get different colours');
else
bad('two different categories returned the same colour');
// -------------------------------------------------------- normalisers
head('LABELS: netifyd slugs are cased, deliberate casing is left alone');
// catLabel() used to lowercase its input before title-casing it. That is
// harmless for netifyd's own categories, which are lowercase slugs, and it
// destroyed appflow's AI categories: "AI assistants" came back as
// "Ai assistants", because the title-case split is on [-_] and a space is
// neither. It rendered that way on a live router before anyone noticed.
//
// Since 1.1.1 the daemon sends those four as slugs, so no category the daemon
// actually emits reaches the pass-through any more. It is kept because it is
// the guard for anything NOT in the table, and these two inputs exercise it
// directly.
//
// SABOTAGE: remove the /[A-Z]/ pass-through in catLabel(). The first two go
// red and the netifyd cases stay green, which is the distinction that matters.
chk('a category with deliberate capitals is left alone',
'AI assistants', c.catLabel('AI assistants'));
chk('and so is a second one', 'AI infrastructure', c.catLabel('AI infrastructure'));
// The netifyd side must be unchanged by that pass-through.
chk('a lowercase netifyd slug is still title-cased', 'Networking', c.catLabel('networking'));
chk('and a hyphenated one still splits', 'Social Network', c.catLabel('social-network'));
chk('an empty category is still Unclassified', 'Unclassified', c.catLabel(''));
head('TILE LETTERS: a name in any script must produce a letter from that script');
// THE DEFECT, found 2026-08-31 by rendering the real component and looking at
// it. tile() stripped leading characters with /^[^0-9A-Za-z]+/, an ASCII class,
// so a name in any non-Latin script was consumed entirely and fell through to
// the '?' fallback. Every Chinese, Japanese, Korean, Russian, Greek, Arabic and
// Hebrew name rendered an identical question-mark tile.
//
// It was never a translation bug. Any LAN client with a non-Latin DHCP hostname
// hit it on every release shipped so far; accepting a zh_Hans catalogue would
// have turned an occasional case into a universal one.
//
// SABOTAGE: put the ASCII class back. The three non-Latin rows go red and every
// Latin row stays green, which is the asymmetry that matters.
chk('a Latin name is unchanged', 'R', textOf(c.tile('Router', 'Router')));
chk('and a hyphenated one', 'K', textOf(c.tile('kitchen-pi', 'kitchen-pi')));
chk('Chinese yields a Chinese character', '路',
textOf(c.tile('路由器', '路由器')));
chk('Japanese yields a Japanese character', '日',
textOf(c.tile('日本語', '日本語')));
chk('Cyrillic yields a Cyrillic character, upper-cased', 'Р',
textOf(c.tile('Русский',
'Русский')));
// The strip must still do its job, and the fallback must still exist. A fix
// that simply removed the strip would pass every row above.
//
// SABOTAGE: delete the .replace() entirely. This row goes red.
chk('leading punctuation is still stripped', 'W', textOf(c.tile('...weird', 'x')));
// SABOTAGE: replace the '?' fallback with ''. This row goes red.
chk('a nameless tile still falls back', '?', textOf(c.tile('', 'x')));
head('LABELS ARE TRANSLATABLE, WHICH IS NOT WHAT THE CHECKS ABOVE PROVE');
// THE DEFECT, found 2026-08-30, reported by @ntbowen against 1.1.0.
//
// READ THIS BEFORE ADDING A CHECK ABOVE INSTEAD OF HERE. Every assertion in
// the group above is satisfied identically by two implementations, one of
// which is broken:
//
// catLabel('networking') -> _('Networking') translatable
// catLabel('networking') -> 'networking'.replace(…) English for ever
//
// Both return the string 'Networking', so `chk(…, 'Networking', …)` passes on
// either. The difference IS the feature, and an equality assertion cannot see
// it. Three separate label paths shipped broken underneath a green run of this
// file, including its own catalogue check, which asserts that every string
// reaching _() is in the .pot and is therefore blind by construction to a
// string that never reaches _() at all.
//
// The observable that distinguishes them is not the output, it is WHETHER _()
// WAS CALLED. tests/luci-module.js records every string the module passes to
// _() as it loads, so the assertion is membership in that set.
//
// NETIFYD_TAGS is a deliberate second copy, read off the bench from
// /etc/netify.d/netify-categories.json on netifyd 4.4.7 (32 application tags
// + 18 protocol tags, 43 unique). It must NOT be imported from the product,
// or this check becomes a tautology that compares the table with itself.
const NETIFYD_TAGS = [
'adult', 'advertiser', 'authentication', 'business', 'cdn', 'cybersecurity',
'database', 'device-iot', 'education', 'entertainment', 'file-server',
'file-sharing', 'financial', 'gambling', 'games', 'government', 'hosting',
'infrastructure', 'mail', 'malware', 'media', 'media-provider', 'messaging',
'networking', 'news', 'os-software-updates', 'portal', 'printing', 'proxy',
'recreation', 'reference', 'remote-desktop', 'shopping', 'social-media',
'sports', 'streaming-media', 'technology', 'telco', 'unclassified', 'voip',
'vpn', 'vpn-and-proxy', 'web'
];
// THE MODULE IS LOADED A SECOND TIME WITH A MARKING TRANSLATOR, and every
// check below runs against that copy rather than against `c`.
//
// The first version of this group asked "is the returned string in the set of
// strings passed to _()". That is weaker than it looks, and a panel named the
// general shape after an executed sabotage had already found one instance:
// with the default identity translator, `_('Web')` and a runtime title-caser
// both produce exactly `'Web'`, so a membership test can be satisfied by a
// COINCIDENCE between an untranslated label and some other string that did go
// through _(). It happened for real with the AI categories, where the table
// entry _('AI media') put that exact string in the set, so a daemon that had
// reverted to sending 'AI media' still passed.
//
// With `_()` returning `[[msgid]]`, a translated label and a generated one are
// different strings, and plain equality becomes a sound test again. `c` keeps
// the identity translator because every other check in this file asserts on
// real English output.
const T = (s) => `[[${s}]]`;
const t9n = load(COMMON, {
rpc: { declare: (o) => () => { throw new Error('rpc called: ' + (o && o.method)); } },
request: { get: () => { throw new Error('request called'); } }
}, { translate: T });
// __translated still records the ARGUMENT to _(), not its return, so the
// catalogue check further down is unaffected by the marking translator.
const translated = new Set(c.__translated.filter(Boolean));
// A check over an empty set passes vacuously. Refuse to report on one, the
// same way the catalogue check below does.
if (translated.size === 0) {
bad('the run reached NO _() strings, so nothing below proves anything');
} else if (t9n.normDevice({}).name !== T('Unknown device')) {
// If the marking translator is not actually reaching the product, every
// equality check below would compare English with English and pass while
// proving nothing. Prove the instrument works before trusting it.
//
// ANCHOR THIS ON A STRING THAT IS NOT IN EITHER TABLE UNDER TEST. The
// first version probed catLabel('web'), which is one of the very entries
// these checks exist to police: unwrapping that entry to `'web': 'Web'`
// made the probe fail, so a real product defect reported itself as a
// broken instrument and skipped the nine checks that would have named it.
// normDevice's fallback is translated on a path no check below asserts on.
bad('the marking translator had no effect, so nothing below proves anything');
} else {
// SABOTAGE: delete any one entry from CATEGORY_LABELS in common.js, or
// write it unwrapped as `'web': 'Web'`. The title-caser still returns the
// same English text and every equality check above stays green; this goes
// red and names the tag.
const untranslatable = NETIFYD_TAGS.filter((t) => t9n.catLabel(t) !== T(c.catLabel(t)));
if (untranslatable.length === 0)
ok(`all ${NETIFYD_TAGS.length} netifyd category tags return the translator's output`);
else
bad(`${untranslatable.length} category tag(s) produce a label _() never saw, `
+ `so they are English in every language: ${JSON.stringify(untranslatable)}`);
// Every one of those labels must also be in the shipped catalogue. The
// check above proves _() was called; this proves the extractor saw it.
//
// SABOTAGE: add a category entry to common.js without regenerating the
// .pot. This goes red; the check above stays green.
const catalogue = potMsgids(POT);
const uncatalogued = NETIFYD_TAGS.map((t) => c.catLabel(t))
.filter((s) => !catalogue.has(s));
if (uncatalogued.length === 0)
ok('and every one of those labels is in appflow.pot');
else
bad(`${uncatalogued.length} label(s) reach _() but are not in the catalogue: `
+ JSON.stringify(uncatalogued.slice(0, 5)));
// The daemon's own AI categories, which is where WE introduced this bug
// rather than inheriting it. Until 1.1.1 appflowd sent 'AI assistants' and
// three siblings as rendered English, and catLabel returned them untouched.
//
// TWO ASSERTIONS, AND THE FIRST ONE IS NOT REDUNDANT. The membership test
// alone cannot catch this: catLabel('AI media') falls through the
// pass-through and returns 'AI media' unchanged, and that exact string IS
// in the translated set, because the table entry 'ai-media': _('AI media')
// put it there. So a daemon that reverted to display strings would satisfy
// a membership check while being exactly as broken as before. That was the
// first version of this check and an executed sabotage caught it, which is
// the only reason it is written this way.
//
// SABOTAGE A: change one AI_HOSTS category in appflowd back to a display
// string, e.g. 'ai-media' -> 'AI media'. The shape check goes red.
// SABOTAGE B: change one to a slug with no table entry, e.g. 'ai-video'.
// The membership check goes red.
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
const daemonCats = daemonAiCategories();
if (daemonCats.length < 4) {
bad(`read only ${daemonCats.length} AI categories out of appflowd; expected 4`);
} else {
const shaped = daemonCats.filter((t) => !SLUG.test(t));
if (shaped.length === 0)
ok(`all ${daemonCats.length} AI categories the daemon emits are lowercase slugs`);
else
bad(`the daemon emits pre-rendered display string(s) ${JSON.stringify(shaped)}; `
+ 'a category that arrives already in English has no key to look up');
const unknown = daemonCats.filter((t) => t9n.catLabel(t) !== T(c.catLabel(t)));
if (unknown.length === 0)
ok(`and the frontend has a _() label for all ${daemonCats.length} of them`);
else
bad(`the daemon emits ${JSON.stringify(unknown)}, which the frontend `
+ 'has no CATEGORY_LABELS entry for');
}
// The three synthetic pseudo-devices. The daemon sends their display name
// already rendered in English because it has no idea what language the
// browser wants, so the frontend must name them from the stable key.
//
// The fixture's `name` is deliberately the real English the daemon sends,
// not a marker string: a lookup keyed on the name rather than the key is a
// plausible implementation and would pass against a fixture that could not
// satisfy it.
//
// SABOTAGE: delete an entry from DEVICE_LABELS, key the lookup on the
// daemon's name instead of t.key, or misspell a key. This goes red.
[ [ 'router', 'Router' ],
[ 'unknown', 'Unknown' ],
[ 'multicast', 'Multicast / Broadcast' ] ].forEach(function(pair) {
const key = pair[0], english = pair[1];
const got = t9n.normDevice({ key: key, name: english }).name;
if (got === T(english))
ok(`pseudo-device '${key}' is named through _(), not from the daemon's English`);
else
bad(`pseudo-device '${key}' rendered ${JSON.stringify(got)}, `
+ `expected the translated ${JSON.stringify(T(english))}`);
});
// A real device must NOT be captured by that table, even when its DHCP
// hostname is exactly the English of a pseudo-device. This is the check
// that a name-keyed implementation fails.
//
// SABOTAGE: index DEVICE_LABELS by the daemon's name rather than t.key.
[ 'kitchen-pi', 'Router', 'Multicast / Broadcast' ].forEach(function(name) {
const got = t9n.normDevice({ key: 'b4:2e:99:3a:d6:df',
mac: 'b4:2e:99:3a:d6:df', name: name }).name;
if (got === name)
ok(`a real device named ${JSON.stringify(name)} keeps its own name`);
else
bad(`a real device named ${JSON.stringify(name)} was renamed to `
+ `${JSON.stringify(got)} by the pseudo-device table`);
});
}
// Both label tables are plain object literals, so they inherit
// Object.prototype and a bare lookup on an inherited key returns a truthy
// non-string. t.name is assumed to be a string by the .toUpperCase() two lines
// later in normDevice, and a label is assumed to be a string by every caller.
//
// Raised independently by two reviewers against the contributor's patch, which
// used a bare `DEVICE_LABELS[key] ||` lookup. color() in the same file already
// guarded its own table this way; catLabel() did not.
//
// THESE PROBES FEED HOSTILE INPUT, SO THEY MUST NOT BE ALLOWED TO THROW
// UNCAUGHT. Removing the guard makes normDevice raise a TypeError rather than
// return a bad value, and an uncaught throw here would abort the whole file:
// node prints a stack trace, every later check never runs, and the summary
// line that says how many passed is never printed at all. The run is still red
// by exit code, but "red" and "red naming the defect, with the other 60 checks
// still reported" are different things. attempt() turns a throw into a named
// failure and lets the suite finish.
//
// SABOTAGE: replace either hasOwnProperty guard with a bare truthiness test.
// Every probe below goes red, by value for catLabel and by throw for
// normDevice, and the checks after this block still report.
function attempt(desc, fn) {
let v;
try {
v = fn();
} catch (e) {
bad(`${desc} threw ${e && e.name}: ${e && e.message}`);
return;
}
if (typeof v === 'string') ok(desc);
else bad(`${desc} produced a ${typeof v}, not a string`);
}
[ '__proto__', 'constructor', 'toString', 'hasOwnProperty' ].forEach(function(k) {
attempt(`catLabel(${JSON.stringify(k)}) returns a string`,
() => c.catLabel(k));
attempt(`normDevice key ${JSON.stringify(k)} names a string`,
() => c.normDevice({ key: k, mac: 'aa:bb:cc:dd:ee:ff' }).name);
});
head('NORMALISERS: idempotent, and never inventing a total');
// THE DEFECT, found 2026-08-25. normSeries() was not idempotent and one caller
// normalised twice. The second pass read the already-renamed fields, found
// nothing, and produced {dl:0, ul:0, total:<real>}: a total contradicting its
// own parts. The chart drew empty while the "no data" message stayed
// suppressed, because `total` was non-zero.
//
// SABOTAGE: remove 'dl' and 'ul' from the alias lists the normaliser accepts.
// The second pass zeroes them and this goes red.
const rawSeries = [{ time: 1, dl: 10, ul: 5 }, { time: 2, dl: 20, ul: 10 }];
const once = c.normSeries(rawSeries);
const twice = c.normSeries(once);
chk('normSeries is idempotent', once, twice);
// SABOTAGE: change the `(tot >= 0) ? tot : dl + ul` fallback to a constant.
chk('a point with no explicit total gets dl+ul', 15, once[0].total);
chk('and the parts survive', [10, 5], [once[0].dl, once[0].ul]);
// The invariant that would have caught the original defect on its own, stated
// as an invariant rather than as a value: parts and total must not contradict.
const contradictions = twice.filter((p) => p.dl === 0 && p.ul === 0 && p.total > 0);
chk('no point claims a total while both parts are zero', [], contradictions);
// SABOTAGE: make normSeries() return its input unchanged. The empty and
// absent cases stop being normalised and this goes red.
chk('normSeries(null) is an empty list', [], c.normSeries(null));
chk('normSeries(undefined) is an empty list', [], c.normSeries(undefined));
head('HELPERS: the accessors every view depends on');
// SABOTAGE for each: change the default-value branch of num()/str()/toList()
// so a miss returns something other than the supplied default.
chk('num() finds a value by alias', 5, c.num({ a: 5 }, ['x', 'a'], 0));
chk('num() returns the default on a miss', 42, c.num({}, ['nope'], 42));
chk('num() returns the default on a non-number', 7, c.num({ a: 'xyz' }, ['a'], 7));
chk('str() returns the default on a miss', 'dflt', c.str({}, ['nope'], 'dflt'));
chk('toList(null) is empty', [], c.toList(null));
chk('toList of an array is itself', [1, 2], c.toList([1, 2]));
// toList over an object keyed by name must carry the key through, or every
// per-device row loses its identity.
//
// SABOTAGE: drop the keyField assignment in toList(). This goes red.
const keyed = c.toList({ 'aa:bb': { bytes: 1 } }, 'mac');
chk('toList carries the object key into the named field', 'aa:bb', keyed[0] && keyed[0].mac);
// ---------------------------------------------------------- catalogue
head('FILTER: include, exclude, and the shapes a half-typed term takes');
// WHY THIS GROUP EXISTS. The filter is the only way to make a busy dashboard
// legible, and the question that produced it was concrete: one streaming
// session buried everything else and there was no way to look past it.
//
// Both views share this logic precisely so they cannot drift, and it lives in
// common.js rather than inside a render function so a test can reach it at all.
// The classifier lesson from the sibling package: logic that decides something
// belongs where Node can load it.
// A term matches the application label, its key, or its category label. Those
// are the three strings a person could plausibly be typing at.
const FIELDS = {
claude: [ 'Anthropic (Claude)', 'ai:anthropic-claude', 'AI assistants' ],
openai: [ 'OpenAI', 'ai:openai', 'AI assistants' ],
netflix: [ 'Netflix', 'a145', 'Streaming Media' ],
http: [ 'HTTP/S', 'p196', 'Web' ]
};
const pass = (text, row) => c.matchFilter(c.parseFilter(text), FIELDS[row]);
// An empty filter passes everything. This is the unfiltered case and must not
// be special-cased by any caller.
//
// SABOTAGE: make matchFilter return false for an empty term list. Every view
// renders an empty table on load, which is as broken as it sounds.
chk('an empty filter passes everything', [ true, true, true, true ],
[ 'claude', 'openai', 'netflix', 'http' ].map((r) => pass('', r)));
// Matching is case-insensitive and matches any of the three fields.
chk('a term matches the application label', true, pass('netflix', 'netflix'));
chk('and is case-insensitive', true, pass('NeTfLiX', 'netflix'));
chk('a term matches the CATEGORY label', true, pass('ai assistants', 'claude'));
chk('a partial category matches', true, pass('ai', 'claude'));
chk('a term matches the internal key', true, pass('p196', 'http'));
chk('a term that matches nothing excludes', false, pass('netflix', 'claude'));
// EXCLUSION. This is the case the whole feature exists for: "show me
// everything except the thing that is burying the view". An include-only
// filter cannot express it, because you would have to already know what you
// wanted to keep.
//
// SABOTAGE: drop the leading-minus branch in parseFilter so '-netflix' is
// treated as an ordinary term. The two checks below invert.
chk('a minus term removes the row it matches', false, pass('-netflix', 'netflix'));
chk('and leaves every other row alone', true, pass('-netflix', 'claude'));
// Terms combine with AND, so each one narrows. Mixing include and exclude is
// the useful case: this category, except that member of it.
//
// SABOTAGE: change every() to some() in matchFilter. The first goes true.
chk('include and exclude combine', false, pass('ai -claude', 'claude'));
chk('and the combination still includes', true, pass('ai -claude', 'openai'));
chk('two includes both have to match', false, pass('ai netflix', 'claude'));
// A bare '-' is what a half-typed exclusion looks like. Treating it as
// "exclude everything" would blank the table between two keystrokes.
//
// SABOTAGE: remove the final filter() in parseFilter that drops empty terms.
chk('a bare minus is ignored, not treated as exclude-all',
[ true, true ], [ pass('-', 'claude'), pass('-', 'netflix') ]);
chk('and so is stray whitespace', true, pass(' ', 'claude'));
// Nulls and absent fields must not throw: a row can legitimately have no
// category, and normApp fills what it can.
//
// SABOTAGE: drop the null guard in matchFilter's field join.
chk('a row with missing fields does not throw', false,
c.matchFilter(c.parseFilter('claude'), [ null, undefined, '' ]));
chk('and still matches on the field it does have', true,
c.matchFilter(c.parseFilter('claude'), [ null, 'Anthropic (Claude)', undefined ]));
head('DEVICE IDENTITY: the key the drill-down is addressed by');
// normDevice DROPPED the daemon's device key. Every call site that needed it
// got undefined, so clicking a device cleared the selection instead of setting
// it: no error, no effect, a feature that looked wired and was not. Three bugs
// in one evening had that exact shape.
//
// SABOTAGE: remove `t.key = ...` from normDevice. This goes red and nothing
// else does, which is precisely why it had to be added.
{
const d = c.normDevice({ key: 'b4:2e:99:3a:d6:df', mac: 'b4:2e:99:3a:d6:df',
ip: '192.168.72.20', name: 'Stang', bytes_total: 10 });
chk('normDevice carries the daemon key through', 'b4:2e:99:3a:d6:df', d.key);
chk('and still carries name, mac and ip', [ 'Stang', 'B4:2E:99:3A:D6:DF', '192.168.72.20' ],
[ d.name, d.mac, d.ip ]);
}
// The pseudo-devices have no MAC at all, so a fallback to mac/ip would leave
// them unaddressable. The daemon's key is what they are called.
//
// SABOTAGE: change the key preference order so mac wins over key.
{
const d = c.normDevice({ key: 'router', name: 'Router', bytes_total: 5 });
chk('a pseudo-device is addressed by its key', 'router', d.key);
}
head('CLICKABLE CELLS: a label that names a thing is a way to see it');
// appCell and deviceCell take an onPick callback. Without one they render
// exactly as before, so nothing that does not want the behaviour gets it.
//
// SABOTAGE: make appCell add af-pick unconditionally. The first check goes red,
// and every table that never wanted click targets grows them.
{
const plain = c.appCell({ label: 'Netflix', key: 'a145', cat: 'streaming-media' });
chk('without a callback an app cell is not clickable',
false, /af-pick/.test(JSON.stringify(plain)));
const picked = [];
const live = c.appCell({ label: 'Netflix', key: 'a145', cat: 'streaming-media' },
null, (v) => picked.push(v));
chk('with a callback it is', true, /af-pick/.test(JSON.stringify(live)));
}
// A device cell with a callback is clickable and passes the whole device
// object, because the caller needs both the key (to query) and the name (to
// show in the chip).
//
// SABOTAGE: have deviceCell pass dev.name instead of dev. The drill-down then
// queries by name, which is not what the daemon keys on.
{
const plain = c.deviceCell({ name: 'Stang', mac: 'B4:2E:99:3A:D6:DF', key: 'b4:2e' });
chk('without a callback a device cell is not clickable',
false, /af-pick/.test(JSON.stringify(plain)));
const live = c.deviceCell({ name: 'Stang', mac: 'B4:2E:99:3A:D6:DF', key: 'b4:2e' },
() => {});
chk('with a callback it is', true, /af-pick/.test(JSON.stringify(live)));
}
head('THE STRING CATALOGUE COVERS WHAT THE CODE ASKS FOR');
// SABOTAGE: add a `_('some new string')` anywhere in common.js without
// regenerating po/templates/appflow.pot. This goes red. Without it the
// catalogue rots silently the moment anyone adds a message, and nobody finds
// out until a translator does.
const pot = potMsgids(POT);
const seen = new Set(c.__translated.filter(Boolean));
// A catalogue check over an empty set proves nothing, so refuse to report a
// pass on one.
if (seen.size === 0) {
bad('the run reached NO _() strings, so this check proved nothing');
} else {
const missing = [...seen].filter((x) => !pot.has(x));
if (missing.length === 0)
ok(`all ${seen.size} strings reached by this run are in the catalogue`);
else
bad(`${missing.length} of ${seen.size} not in appflow.pot: `
+ JSON.stringify(missing.slice(0, 5)));
}
// The catalogue must also not be EMPTY or trivially small, which would make
// the check above pass while covering nothing.
//
// SABOTAGE: truncate appflow.pot. This goes red.
if (pot.size >= 100) ok(`the catalogue holds ${pot.size} msgids`);
else bad(`the catalogue holds only ${pot.size} msgids; expected the full extraction`);
console.log('\n----------------------------------------');
console.log(`passed ${PASS}, failed ${FAIL}`);
process.exit(FAIL);
+422
View File
@@ -0,0 +1,422 @@
#!/bin/sh
# appflow hardware test suite.
#
# The behavioural claims this package makes that can be checked without a
# browser, as one runnable script. It exists because all of them had previously
# been verified only by ad-hoc commands typed once and thrown away, and because
# two of this daemon's defects were silent: they produced wrong numbers while
# every status field said things were fine.
#
# RUN IT ON A SANDBOX ROUTER. It restarts appflowd, resets its counters, and
# generates traffic. It does not modify configuration.
#
# usage: sh hardware-suite.sh run everything
# sh hardware-suite.sh conservation run one group
# (health|conservation|conntrack|restart|acl)
#
# Exit status is the number of failed checks.
#
# WHAT THIS SUITE CAN AND CANNOT CATCH, established by running it against the
# pre-fix v1.0.1 daemon on 2026-08-25, which is the only test of a test worth
# anything:
#
# caught: an old daemon with no conservation counters at all
# (status.bytes missing), and any leak once they exist
# NOT caught: the reshadow double-count itself. The restart ratio came back
# 1.012 on the known-broken daemon, having been 0.70 and 0.79 on
# two runs the day before. See the note in test_restart.
#
# Unlike the wansentry suite, nothing here reimplements the code under test:
# every check queries the running daemon over ubus. The weakness is coverage,
# not construction.
#
# WIRE TRUTH. Where a test needs to know how many bytes really moved, it reads
# the client veth's own counter in /sys/class/net. Nothing in appflowd can
# influence that number, which is the entire point: comparing appflow against
# appflow proves nothing.
#
# Requires: appflowd and netifyd running, root, and a network namespace client
# (see NETNS below) for the NAT tests. Groups that need the netns skip
# themselves with a clear message when it is absent rather than failing.
set -u
GROUP="${1:-all}"
NETNS="${NETNS:-cli}"
VETH="${VETH:-veth-host}"
PASS=0; FAIL=0; SKIP=0
head2() { printf '\n=== %s ===\n' "$*"; }
ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$*"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$*"; }
skip() { SKIP=$((SKIP+1)); printf ' SKIP %s\n' "$*"; }
st() { ubus call appflow status 2>/dev/null; }
# field <group> <key> e.g. field bytes leaked
field() {
st | sed -n "/\"$1\"/,/}/p" | sed -n "s/.*\"$2\": *\([0-9-]*\).*/\1/p" | head -1
}
wire() { cat "/sys/class/net/$VETH/statistics/tx_bytes" 2>/dev/null || echo 0; }
have_netns() {
ip netns list 2>/dev/null | grep -q "^$NETNS" && [ -r "/sys/class/net/$VETH/statistics/tx_bytes" ]
}
# A sustained NAT-ed transfer. The WAN here moves 50 MB in about a second, so an
# ordinary download finishes long before anything interesting happens; the
# throttle is TCP backpressure from a consumer that reads a fixed amount per
# second. head -c reads an exact count, dd on a pipe does short reads and
# throttles to a trickle instead.
slow_pull() { # slow_pull <seconds> <blocks-per-second>
local secs="$1" bps="$2"
ip netns exec "$NETNS" sh -c "
{ printf 'GET /__down?bytes=40000000 HTTP/1.0\r\nHost: speed.cloudflare.com\r\nConnection: close\r\n\r\n'
sleep $secs
} | socat - TCP:speed.cloudflare.com:80 2>/dev/null | {
n=0
while [ \"\$n\" -lt $secs ]; do
head -c \$(( $bps * 65536 )) > /dev/null 2>&1 || break
n=\$(( n + 1 )); sleep 1
done
}" >/dev/null 2>&1
}
ratio() { awk -v a="$1" -v w="$2" 'BEGIN{ if (w<=0) print "0"; else printf "%.3f", a/w }'; }
within() { # within <value> <lo> <hi>
awk -v v="$1" -v lo="$2" -v hi="$3" 'BEGIN{ exit !(v>=lo && v<=hi) }'
}
# ---------------------------------------------------------------- health
test_health() {
head2 "DAEMON HEALTH"
# busybox pgrep has no -c, so count the lines rather than asking it to.
if pgrep -f '[a]ppflowd' >/dev/null 2>&1; then
ok "appflowd is running"
else
bad "appflowd is not running"; return
fi
[ -n "$(st)" ] && ok "ubus object 'appflow' answers" \
|| bad "ubus call appflow status returned nothing"
case "$(st | sed -n 's/.*"connected": *\(true\|false\).*/\1/p' | head -1)" in
true) ok "connected to netifyd's export socket" ;;
*) bad "not connected to netifyd" ;;
esac
# A daemon that has never seen an event is not a healthy daemon, it is a
# daemon whose socket happens to be open.
# Default the value: an empty field makes [ -gt ] abort the whole script with
# "out of range" instead of simply failing this one check.
local seen; seen=$(field flows seen); seen=${seen:-0}
if [ "$seen" -gt 0 ] 2>/dev/null; then
ok "flows have been observed (seen=$seen)"
else
bad "no flows observed at all"
fi
}
# ---------------------------------------------------------- conservation
test_conservation() {
head2 "BYTE CONSERVATION (reported == attributed + shadowed)"
# Both silent byte-loss defects this daemon has had would have shown here in
# the first minute. This is the check that makes them loud.
have_netns || { skip "needs netns '$NETNS' (conservation)"; return; }
ubus call appflow reset >/dev/null 2>&1; sleep 2
slow_pull 20 6
sleep 25
local rep att sh lk
rep=$(field bytes reported); att=$(field bytes attributed)
sh=$(field bytes shadowed); lk=$(field bytes leaked)
if [ -z "$rep" ] || [ -z "$lk" ]; then
bad "status.bytes is missing (is this appflow >= 1.0.2?)"; return
fi
printf ' reported=%s attributed=%s shadowed=%s leaked=%s\n' "$rep" "$att" "$sh" "$lk"
[ "$lk" = "0" ] && ok "leaked == 0 (nothing reported went unaccounted)" \
|| bad "leaked == $lk, so bytes were reported and never attributed"
[ "${rep:-0}" -gt 0 ] 2>/dev/null && ok "traffic was actually observed" \
|| bad "no bytes reported; the transfer did not happen"
# purge_only_rescued counts flows that were purged having never received a
# flow_stats. Those carry their whole byte count on the purge event, and
# before this path existed every one of them was silently discarded.
#
# It is NOT expected to be zero, and an earlier version of this suite
# asserted that it was. Measured on this hardware: 13 of 1341 flows, 0.97%.
# A 38-flow event-socket capture had seen none, which at that rate has an
# expected count of 0.4 -- so the sample was far too small to support the
# "netifyd never does this" conclusion that was briefly drawn from it, and
# the review panel that reported the byte loss was right.
#
# What is worth watching is the RATE. A sudden jump means netifyd's event
# contract has shifted; zero across a busy run means this path stopped
# firing and the rescue may have regressed.
local po seen; po=$(field bytes purge_only_rescued); po=${po:-0}
seen=$(field flows seen); seen=${seen:-0}
if [ "$seen" -gt 200 ] 2>/dev/null; then
printf ' purge_only_rescued=%s of %s flows (%s%%)\n' "$po" "$seen" \
"$(awk -v p="$po" -v s="$seen" 'BEGIN{printf "%.2f", 100*p/s}')"
if awk -v p="$po" -v s="$seen" 'BEGIN{exit !(100*p/s <= 10)}'; then
ok "purge-only rescues are a small fraction of flows"
else
bad "purge_only_rescued is $po of $seen flows; netifyd's contract may have shifted"
fi
else
printf ' purge_only_rescued=%s (only %s flows seen; rate not meaningful)\n' "$po" "$seen"
skip "too few flows to judge the purge-only rate"
fi
}
# ---------------------------------------------------------------- conntrack
test_conntrack() {
head2 "NAT TWIN RESOLUTION (conntrack)"
have_netns || { skip "needs netns '$NETNS' (conntrack)"; return; }
case "$(st | sed -n 's/.*"available": *\(true\|false\|null\).*/\1/p' | head -1)" in
true) ok "conntrack is readable" ;;
*) bad "conntrack unavailable; NAT twins cannot be resolved" ;;
esac
ubus call appflow reset >/dev/null 2>&1; sleep 2
slow_pull 15 6
sleep 20
local tw un
tw=$(field conntrack twins); un=$(field conntrack unresolved)
printf ' twins=%s router_local=%s unresolved=%s entries=%s\n' \
"$tw" "$(field conntrack router_local)" "$un" "$(field conntrack entries)"
# The first version of this fix cached the snapshot for 2 s, so new flows
# almost always missed and it silently degraded to the pre-fix behaviour
# while reporting conntrack as available. High unresolved against zero twins
# is exactly that signature.
if [ "${tw:-0}" -gt 0 ] 2>/dev/null; then
ok "NAT twins are being resolved on evidence"
elif [ "${un:-0}" -gt 0 ] 2>/dev/null; then
bad "unresolved=$un with twins=0: resolution is degrading to the safe default"
else
skip "no external captures in this window; nothing to resolve"
fi
}
# ---------------------------------------------------------------- restart
test_restart() {
head2 "RESTART MID-TRANSFER (reshadow must not double count)"
have_netns || { skip "needs netns '$NETNS' (restart)"; return; }
# reshadow() used to return early on dev_key == "router", which is exactly
# how a WAN-side capture of a NAT-ed client flow is classified, so after a
# restart both copies of a flow stayed counted for its whole remaining life.
#
# READ THIS BEFORE TRUSTING A GREEN RESULT HERE. This check is a broad
# regression guard, not a reliable detector of that defect. Measured against
# the pre-fix v1.0.1 daemon: 0.701 and 0.791 on two runs on 2026-08-24, then
# 1.012 on 2026-08-25 -- the same broken code passing, because whether the
# defect manifests depends on a NAT-ed flow actually spanning the restart
# and being captured on both interfaces, and a sixty-second window does not
# guarantee that.
#
# So: a RED result here is strong evidence of a real problem. A GREEN result
# is weak evidence of correctness, and one green run is not proof the
# attribution is sound. The conservation counters above are the dependable
# signal; this one catches gross breakage.
# PRECONDITION, CHECKED RATHER THAN ASSUMED: netifyd must actually capture
# the path this traffic takes. `wire()` reads the test client's own veth, so
# it counts every byte the client sent regardless of which uplink carried
# them, while appflow can only ever count what netifyd was told to watch.
#
# This bit on 2026-08-27. A bench router had a second uplink and a VPN
# tunnel added to it while netifyd still ran `-I br-lan -E wan`, and the
# ratio dropped to 0.62. That reads as an attribution defect and is not one:
# the traffic left by a path netifyd was never watching. The unmodified
# shipped daemon produced the same failure, which is the only reason it was
# not chased as a regression.
#
# A ratio computed over an uncaptured path is not a weak signal, it is a
# meaningless one, so this skips loudly instead of reporting a number.
local caps uncap
caps=$(ps w 2>/dev/null | sed -n 's/.*netifyd.*/&/p' | head -1)
uncap=""
for d in $(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | sort -u); do
case " $caps " in
*" $d "*) ;;
*) uncap="$uncap $d" ;;
esac
done
if [ -n "$uncap" ]; then
skip "netifyd does not capture default-route interface(s):$uncap -- ratio would be meaningless"
[ "$(field bytes leaked)" = "0" ] && ok "leaked still 0" || bad "leaked != 0"
return 0
fi
ubus call appflow reset >/dev/null 2>&1; sleep 2
local w0; w0=$(wire)
slow_pull 45 6 &
local job=$!
sleep 12
/etc/init.d/appflowd restart >/dev/null 2>&1
sleep 40
wait $job 2>/dev/null
sleep 20
local w1 moved tot r
w1=$(wire); moved=$(( w1 - w0 ))
tot=$(st | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
[ -z "$tot" ] && tot=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
r=$(ratio "${tot:-0}" "$moved")
printf ' wire=%s appflow=%s ratio=%s\n' "$moved" "${tot:-0}" "$r"
if [ "$moved" -lt 100000 ]; then
skip "not enough traffic moved to judge the ratio"
elif within "$r" 0.80 1.25; then
ok "total stays near wire truth across a restart (ratio $r)"
else
bad "ratio $r is outside 0.80-1.25; a restart is mis-attributing"
fi
[ "$(field bytes leaked)" = "0" ] && ok "leaked still 0 after a restart" \
|| bad "leaked != 0 after a restart"
}
# ---------------------------------------------------------------- acl
test_categories() {
head2 "Every category this box's netifyd can emit has a translatable label"
# THE DRIFT GAP, and it is the one thing the offline suite structurally
# cannot close. frontend-suite.js checks CATEGORY_LABELS against a list of
# 43 tags hand-copied from netifyd 4.4.7. That catches a deleted entry. It
# cannot catch netifyd ADDING a tag, because a list written today does not
# know about it, and the product's response to an unknown tag is to
# title-case it into credible English that is untranslatable for ever.
#
# Only a real box knows what its own netifyd carries. This check reads the
# installed category file and asserts the frontend has a label for every
# tag in it, so a netifyd upgrade that adds a category becomes a visible
# test failure rather than a silently English label.
#
# SABOTAGE: delete any entry from CATEGORY_LABELS in common.js. This goes
# red and names the tag. To see it fire for real, add a tag to
# /etc/netify.d/netify-categories.json that the frontend does not know.
local CATS=/etc/netify.d/netify-categories.json
local JS=/www/luci-static/resources/view/appflow/common.js
[ -f "$CATS" ] || { skip "netify category file absent ($CATS)"; return; }
[ -f "$JS" ] || { skip "appflow frontend not installed ($JS)"; return; }
local tags missing n
tags=$(jsonfilter -i "$CATS" -e '@.application_tag_index' \
-e '@.protocol_tag_index' 2>/dev/null |
grep -o '"[a-z][a-z0-9-]*"' | tr -d '"' | sort -u)
if [ -z "$tags" ]; then
skip "could not read any category tag out of $CATS"
return
fi
n=$(printf '%s\n' "$tags" | grep -c .)
missing=""
for t in $tags; do
# The label table is `'tag': _('Label'),`. Matching the quoted key
# against the file is enough: an entry that exists but is not wrapped
# in _() is the offline suite's job, and it checks exactly that.
grep -q "'$t':" "$JS" || missing="$missing $t"
done
if [ -z "$missing" ]; then
ok "all $n category tags on this box have a CATEGORY_LABELS entry"
else
bad "netifyd carries category tags the frontend cannot translate:$missing"
fi
}
test_acl() {
head2 "ACL grants no more than the package uses"
A=/usr/share/rpcd/acl.d/luci-app-appflow.json
[ -f "$A" ] || { bad "ACL file not installed"; return; }
# /etc/config/network holds PPPoE and 802.1x credentials and wireguard keys.
# A sibling package had this grant removed on 2026-08-25 for that reason.
if grep -q '"network"' "$A"; then
bad "ACL grants uci read on 'network' (PPPoE/wireguard secrets)"
else
ok "ACL does not grant uci read on 'network'"
fi
# appflow reads nothing but its own ubus object, so a uci grant of any kind
# would be a grant it does not use.
if grep -q '"uci"' "$A"; then
bad "ACL contains a uci grant; this package only uses its own ubus object"
else
ok "ACL contains no uci grant at all"
fi
if grep -q '"file"' "$A"; then
bad "ACL contains a file grant; this package shells out to nothing"
else
ok "ACL contains no file/exec grant"
fi
# There IS a legitimate write: `reset` clears the live counters and the UI
# offers it as a button. What must not appear is a write to anything else.
W=$(sed -n '/"write"/,$p' "$A" | grep -oE '"[a-z_]+"' | grep -vE '"write"|"ubus"|"appflow"' | tr -d '"' | tr '\n' ' ')
if [ "$(echo "$W" | tr -d ' ')" = "reset" ]; then
ok "the only write grant is appflow.reset"
else
bad "write grants beyond appflow.reset: $W"
fi
# The grant list must not exceed what the frontend actually binds. Checking
# only for absent CATEGORIES missed this: the ACL granted `apps` and `flows`
# that no view calls, and `flows` is the live per-connection table rather
# than an aggregate. The code comment even said the two were deliberately
# unbound, so the evidence was sitting next to the defect.
V=/www/luci-static/resources/view/appflow/common.js
if [ -r "$V" ]; then
DECL=$(grep -oE "declare\('[a-z_]+'" "$V" | sed "s/declare('//;s/'//" | sort -u)
GRANT=$(sed -n '/"read"/,/"write"/p' "$A" | grep -oE '"[a-z_]+"' \
| grep -vE '"read"|"write"|"ubus"|"appflow"' | tr -d '"' | sort -u)
EXTRA=$(for g in $GRANT; do echo "$DECL" | grep -qx "$g" || echo "$g"; done | tr '\n' ' ')
if [ -z "$(echo "$EXTRA" | tr -d ' ')" ]; then
ok "every granted read method is one the frontend declares"
else
bad "granted but never called by any view: $EXTRA"
fi
else
skip "frontend not installed; cannot compare grants against declares"
fi
}
# ---------------------------------------------------------------- run
printf 'appflow hardware suite\n'
printf 'group: %s netns: %s veth: %s\n' "$GROUP" "$NETNS" "$VETH"
have_netns || printf 'note: netns/veth absent; traffic tests will skip\n'
case "$GROUP" in
health) test_health ;;
conservation) test_conservation ;;
conntrack) test_conntrack ;;
restart) test_restart ;;
acl) test_acl ;;
categories) test_categories ;;
all) test_health; test_conservation; test_conntrack
test_restart; test_acl; test_categories ;;
*) printf "unknown group '%s'\n" "$GROUP"; exit 2 ;;
esac
printf '\n----------------------------------------\n'
printf 'passed %d, failed %d, skipped %d\n' "$PASS" "$FAIL" "$SKIP"
exit $FAIL
+252
View File
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 VolanticSystems
//
// Load a real LuCI view module under Node, unmodified, so its exported pure
// functions can be tested without a browser.
//
// WHY THIS EXISTS. Every browser-side defect this package has had was found by
// a human or a review panel reading the code. Not one was found by a test,
// because no test could run any of it: LuCI view files are not CommonJS or ES
// modules, they are evaluated by LuCI's own loader with the `'require x';`
// directives intercepted. So the JavaScript half of a package whose entire
// safety promise lives in `audit()` had zero automated coverage.
//
// WHAT IS AND IS NOT STUBBED, because this is where a harness like this goes
// wrong. The stubs supply DATA and inert plumbing only:
//
// baseclass.extend(o) -> returns o. LuCI returns a class whose singleton
// exposes the same properties; callers use it as
// `gen.audit()`, so a plain object is behaviourally
// identical for these tests.
// uci.sections(pkg) -> returns the fixture array the test supplied.
// rpc / request -> throw if called. Nothing under test calls them,
// and a stub that silently returned a plausible
// value would be the reimplementation trap: the
// test would pass against a product that had
// stopped asking.
//
// NO LOGIC FROM THE MODULE IS COPIED HERE. The classification, generation and
// validation code that runs is the shipped code, byte for byte.
'use strict';
const fs = require('fs');
const vm = require('vm');
const path = require('path');
// Strip the LuCI loader directives. They are bare string-expression statements
// at the top of the file, exactly `'require name';` or `'require name as x';`,
// and they are the only thing preventing the file from evaluating as ordinary
// JavaScript. Replaced with a blank line each so every reported line number
// still matches the real file.
// The same String.prototype.format, as SOURCE TEXT, so it can be evaluated
// inside the vm realm where the module actually runs.
const FORMAT_SRC = `
Object.defineProperty(String.prototype, 'format', {
value: function () {
var args = arguments, i = 0;
return this.replace(/%(?:(\d+)\$)?([sdifj%])/g, function (m, pos, kind) {
if (kind === '%') return '%';
var v = args[pos ? (parseInt(pos, 10) - 1) : i++];
if (kind === 'd' || kind === 'i') return String(parseInt(v, 10));
if (kind === 'f') return String(parseFloat(v));
if (kind === 'j') return JSON.stringify(v);
return (v == null) ? '' : String(v);
});
}
});
`;
function stripRequires(src) {
return src.replace(/^\s*'require [^']*';\s*$/gm, '');
}
// load(file, stubs, opts) -> the object the module returns.
//
// opts.translate replaces the _() implementation. Default is identity, which
// is LuCI's real behaviour with no catalogue loaded.
//
// WHY A NON-IDENTITY TRANSLATOR IS WORTH HAVING. With identity, a label that
// went through _() and a label a runtime title-caser produced are the same
// string, so no assertion on the OUTPUT can tell them apart. That is not
// hypothetical: three label paths shipped untranslatable underneath a green
// run of a suite that asserted exactly those outputs. Loading the module a
// second time with a marking translator makes the two observationally
// different, and equality assertions become sound again rather than
// coincidentally true. Raised by a review panel, 2026-08-30.
function load(file, stubs, opts) {
const translate = (opts && opts.translate) || ((s) => s);
const src = fs.readFileSync(file, 'utf8');
// Every string passed to _() while the module evaluates, in order. This is
// not decoration: it lets a test assert that the shipped catalogue
// (po/templates/*.pot) actually covers the strings the code asks for, which
// is otherwise a claim nobody checks until a translator files a bug.
const translated = [];
const sandbox = Object.assign({
baseclass: { extend: (o) => o },
// LuCI's _() returns its msgid unchanged when no catalogue is loaded,
// which is the default English case, so identity is the real
// behaviour rather than a convenient stand-in.
_: (s) => { translated.push(s); return translate(s); },
N_: (n, s, p) => {
translated.push(s); translated.push(p);
return translate(n === 1 ? s : p);
},
L: {
// L.resource() builds a static asset URL. It is a pure string
// join in LuCI and is used at module top level, so it has to
// return something or the file will not evaluate. This is the one
// stub that returns a value rather than throwing, and it returns
// an obviously-fake path so a test that accidentally depends on it
// fails visibly rather than plausibly.
resource: (...p) => '/TEST-RESOURCE/' + p.join('/'),
// L.toArray is a LuCI RUNTIME helper, not product logic, and this
// is a faithful copy of it from luci-base's luci.js rather than an
// approximation. Getting it wrong would make normalize() behave
// differently here than in a browser, which is the one way a
// harness like this lies convincingly: the product code would be
// real and the answer would still be wrong.
//
// null/undefined -> []
// array -> itself
// object -> [ it ]
// '' -> []
// otherwise -> String(val).trim().split(/\s+/)
toArray: (val) => {
if (val == null) return [];
if (Array.isArray(val)) return val;
if (typeof val === 'object') return [ val ];
const s = String(val).trim();
return s === '' ? [] : s.split(/\s+/);
},
// Everything else throws rather than returning something
// plausible. A stub that silently answers is how a test starts
// passing against a product that has stopped asking.
error: (e) => { throw new Error('L.error: ' + e); },
raise: (e) => { throw new Error('L.raise: ' + e); }
},
// LuCI's DOM element factory. The real one returns a live DOM node;
// this returns an inspectable plain tree of the same shape.
//
// BE HONEST ABOUT WHAT THIS CAN AND CANNOT TEST. It shows which
// element, class and text the code CHOSE to emit, which is exactly
// where this project's browser defects have lived: a status pill that
// rendered "healthy" for an unknown state, a tile whose colour came
// back undefined. It says nothing about layout, CSS cascade, or what
// a browser finally paints. Those still need a browser.
E: function (tag, attrs, children) {
if (attrs != null && (typeof attrs !== 'object' || Array.isArray(attrs))) {
children = attrs;
attrs = {};
}
return {
tag: tag,
attrs: attrs || {},
children: children == null ? [] : [].concat(children)
};
},
console: console
}, stubs || {});
// The module body ends in a top-level `return`, which is only legal inside
// a function, so wrap it in one.
const fn = new vm.Script(
'(function(){\n' + stripRequires(src) + '\n})()',
{ filename: path.basename(file) }
);
// vm gives the module its own REALM, with its own String.prototype, so a
// polyfill installed in Node's realm does not reach it. Build the context
// first, install the LuCI runtime extensions INSIDE it, then evaluate.
// Without this, product code calling '%s'.format() throws here while
// working perfectly in a browser.
const ctx = vm.createContext(sandbox);
new vm.Script(FORMAT_SRC, { filename: 'luci-runtime-shim.js' }).runInContext(ctx);
const mod = fn.runInContext(ctx);
// Non-enumerable so it cannot be mistaken for part of the module's own
// export surface by a test that iterates the keys.
Object.defineProperty(mod, '__translated', {
value: translated, enumerable: false
});
return mod;
}
// Every msgid in a .pot file. Used to assert the catalogue covers the strings
// the code actually asks for.
function potMsgids(file) {
const out = new Set();
const re = /^msgid "((?:[^"\\]|\\.)*)"/gm;
const src = fs.readFileSync(file, 'utf8');
let m;
while ((m = re.exec(src)) !== null) {
const s = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\')
.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
if (s) out.add(s);
}
return out;
}
// A uci stub whose sections() returns fixture data in LuCI's own shape:
// each section is a flat object of its options plus '.name' and '.type'.
function uciStub(sectionsByPackage) {
return {
sections: (pkg) => (sectionsByPackage[pkg] || []),
get: (pkg, sec, opt) => {
const list = sectionsByPackage[pkg] || [];
const s = list.find((x) => x['.name'] === sec);
return s ? s[opt] : undefined;
}
};
}
// Parse the output of `uci show <pkg>` into LuCI section objects.
//
// This is here so a fixture can be written in the SAME text the init script
// parses, rather than as hand-built objects that have quietly drifted from
// what uci actually emits. The two halves of the ownership rule are supposed
// to see the same configuration; letting them be described twice, in two
// notations, is how they stopped agreeing in the first place.
function parseUciShow(text) {
const sections = [];
const byName = {};
for (const line of text.split('\n')) {
const t = line.trim();
if (!t) continue;
let m = t.match(/^([a-z0-9_-]+)\.([^.=]+)=(\S+)$/i);
if (m) {
const sec = { '.name': m[2], '.type': m[3] };
byName[m[2]] = sec;
sections.push(sec);
continue;
}
m = t.match(/^([a-z0-9_-]+)\.([^.=]+)\.([^=]+)='(.*)'$/i);
if (m && byName[m[2]]) byName[m[2]][m[3]] = m[4];
}
return sections;
}
// Flatten an E()-tree to its visible text, and to the set of class names used.
// Both are what a test wants to assert on: "which pill did it choose", not
// "what did the browser paint".
function textOf(node) {
if (node == null || node === false) return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (node.children) return node.children.map(textOf).join('');
return '';
}
function classesOf(node, out) {
out = out || new Set();
if (node == null || typeof node !== 'object') return out;
if (Array.isArray(node)) { node.forEach((n) => classesOf(n, out)); return out; }
const c = node.attrs && (node.attrs['class'] || node.attrs.class);
if (typeof c === 'string') c.trim().split(/\s+/).forEach((x) => x && out.add(x));
(node.children || []).forEach((n) => classesOf(n, out));
return out;
}
module.exports = { load, uciStub, parseUciShow, stripRequires, potMsgids, textOf, classesOf };
File diff suppressed because it is too large. Load diff