Add test coverage for the Angular bundle patch
patch_ui.py rewrites minified third-party JavaScript by regex and had no tests. It is the easiest place in this project to do real damage: a pattern that matches nothing silently leaves the dropdown Storj-only, and one that consumes a paren too many is a syntax error in the bundle that blanks the entire TrueNAS web UI. Tests run the real patterns against verbatim snippets from a TrueNAS 25.x chunk-*.js (the chained property(...)(...) form) and a 24.x literal array, and assert: exactly one match, all three providers present, the paren balance is UNCHANGED, surrounding code untouched, re-patching is a no-op, unrelated JS is never matched, and every pattern stays anchored to filterByProviders. The paren-balance assertion is the load-bearing one -- a plausible-but-wrong pattern that eats both parens and re-emits none shifts the delta from 1 to 2 and is caught. Also restore the comment explaining why the 25.x pattern is shaped the way it is, and correct the module docstring, which showed the binding with a single closing paren; the real bundle wraps it in a chained property call. 90 tests, ruff and shellcheck clean.
This commit is contained in:
+14
-4
@@ -9,8 +9,8 @@ in the minified JS in one of two forms depending on TrueNAS / Angular version:
|
||||
TrueNAS 24.x (static inline array):
|
||||
"filterByProviders",["STORJ_IX"]
|
||||
|
||||
TrueNAS 25.x+ (Angular pureFunction binding):
|
||||
"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj)
|
||||
TrueNAS 25.x+ (Angular pureFunction binding, inside a chained property call):
|
||||
c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))("required",!0)
|
||||
|
||||
Both are replaced so the dropdown includes S3 and B2. The file is backed up
|
||||
before modification so uninstall.sh can restore it.
|
||||
@@ -33,11 +33,21 @@ WEBUI_CANDIDATES = [
|
||||
# Patterns tried in order; the first match wins.
|
||||
# Each entry is (compiled_regex, replacement_string).
|
||||
_PATTERNS = [
|
||||
# TrueNAS 25.x+: Angular emits a pureFunction call instead of a literal array.
|
||||
# TrueNAS 25.x+: Angular emits a pureFunction call instead of a literal array,
|
||||
# inside a CHAINED property binding — so the call is followed by two closing
|
||||
# parens, one for pe(...) and one for the property(...) it sits in:
|
||||
#
|
||||
# c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))("required",!0)
|
||||
# ^^
|
||||
# The pattern consumes both and re-emits one, leaving the paren balance
|
||||
# unchanged. Getting that wrong is a syntax error in the bundle and the whole
|
||||
# web UI goes blank — see tests/test_patch_ui.py.
|
||||
#
|
||||
# The minified names (pe / slot index / Rn / i) change across builds;
|
||||
# CloudSyncProviderName.Storj is stable because it is a TypeScript enum name.
|
||||
(re.compile(r'("filterByProviders",)\w+\(\d+,\w+,\w+\.CloudSyncProviderName\.Storj\)\)'),
|
||||
r'\1["STORJ_IX","S3","B2"])'),
|
||||
|
||||
|
||||
# TrueNAS 24.x and earlier: static inline array.
|
||||
(re.compile(r'("filterByProviders",)\["STORJ_IX"\]'),
|
||||
r'\1["STORJ_IX","S3","B2"]'),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the Angular bundle patch.
|
||||
|
||||
This is the one part of the patch that edits *minified third-party JavaScript* by
|
||||
regex, so it is the easiest place to silently produce a broken bundle: a pattern
|
||||
that matches nothing leaves the dropdown Storj-only, and a pattern that matches
|
||||
sloppily can unbalance the parentheses and take the whole web UI down.
|
||||
|
||||
Nothing checked it until now. The snippets below are verbatim from a real
|
||||
TrueNAS 25.x bundle (chunk-*.js, pre-patch).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "patch"))
|
||||
|
||||
from patch_ui import MARKER, _PATTERNS, _match_pattern # noqa: E402
|
||||
|
||||
# Verbatim from /usr/share/truenas/webui/chunk-FX2QXNQU.js on TrueNAS 25.10.
|
||||
# Angular emits the binding as a chained ɵɵproperty(...)(...) call, so the
|
||||
# pureFunction call is followed by TWO closing parens: one for pe(...), one for
|
||||
# property(...).
|
||||
REAL_25X = (
|
||||
'c(2,"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj))'
|
||||
'("required",!0),r(3'
|
||||
)
|
||||
|
||||
# TrueNAS 24.x and earlier emitted a literal array.
|
||||
REAL_24X = 'c(2,"filterByProviders",["STORJ_IX"])("required",!0),r(3'
|
||||
|
||||
|
||||
def apply_patch(content):
|
||||
"""Run the same match-and-substitute main() does."""
|
||||
find, replace = _match_pattern(content)
|
||||
assert find is not None, "no pattern matched"
|
||||
patched, count = find.subn(replace, content)
|
||||
return patched, count
|
||||
|
||||
|
||||
def paren_delta(s):
|
||||
"""Net paren balance. The snippets are fragments of a minified file, so they
|
||||
are not balanced on their own -- what must hold is that patching does not
|
||||
CHANGE the balance. Consuming one paren too many is a syntax error in the
|
||||
bundle, and the whole TrueNAS web UI goes blank."""
|
||||
return s.count("(") - s.count(")")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [REAL_25X, REAL_24X], ids=["25.x", "24.x"])
|
||||
class TestAgainstRealBundles:
|
||||
def test_matches_exactly_once(self, source):
|
||||
# main() refuses to write unless count == 1 — more than one match would
|
||||
# mean the pattern is too loose to trust against a minified bundle.
|
||||
_patched, count = apply_patch(source)
|
||||
assert count == 1
|
||||
|
||||
def test_result_contains_all_three_providers(self, source):
|
||||
patched, _ = apply_patch(source)
|
||||
assert MARKER in patched
|
||||
assert '"filterByProviders",["STORJ_IX","S3","B2"]' in patched
|
||||
|
||||
def test_patch_does_not_change_paren_balance(self, source):
|
||||
# Consuming one paren too many (or too few) is a syntax error in the
|
||||
# bundle and the entire TrueNAS web UI goes blank. This is the invariant
|
||||
# the 25.x pattern has to get right: it eats `pe(...)` which sits inside
|
||||
# a chained property(...)(...) call.
|
||||
patched, _ = apply_patch(source)
|
||||
assert paren_delta(patched) == paren_delta(source)
|
||||
|
||||
def test_surrounding_code_is_untouched(self, source):
|
||||
patched, _ = apply_patch(source)
|
||||
assert patched.startswith("c(2,")
|
||||
assert patched.endswith('("required",!0),r(3')
|
||||
|
||||
def test_patch_is_idempotent(self, source):
|
||||
# apply.sh re-runs every boot; MARKER short-circuits an already-patched
|
||||
# file, but the pattern must also not match its own output.
|
||||
patched, _ = apply_patch(source)
|
||||
find, _replace = _match_pattern(patched)
|
||||
if find is not None:
|
||||
# Only the 24.x literal-array pattern may still "match" — and only if
|
||||
# it would produce the same text. Anything else means double-patching.
|
||||
again, _ = apply_patch(patched)
|
||||
assert again == patched, "re-patching must be a no-op"
|
||||
|
||||
|
||||
def test_storj_only_bundle_is_recognised():
|
||||
assert _match_pattern(REAL_25X)[0] is not None
|
||||
|
||||
|
||||
def test_unrelated_javascript_is_never_touched():
|
||||
# A pattern loose enough to hit unrelated code would corrupt the bundle.
|
||||
for noise in (
|
||||
'c(2,"filterByProviders",pe(115,Rn,i.SomethingElse.Storj))',
|
||||
'c(2,"otherBinding",pe(115,Rn,i.CloudSyncProviderName.Storj))',
|
||||
'"filterByProviders"',
|
||||
):
|
||||
find, _ = _match_pattern(noise)
|
||||
assert find is None, f"pattern must not match: {noise}"
|
||||
|
||||
|
||||
def test_every_pattern_is_anchored_to_filterbyproviders():
|
||||
# Guards against a future pattern broad enough to rewrite arbitrary JS.
|
||||
for find, _replace in _PATTERNS:
|
||||
assert "filterByProviders" in find.pattern
|
||||
|
||||
|
||||
def test_patterns_compile_and_replacements_reference_group_one():
|
||||
for find, replace in _PATTERNS:
|
||||
assert isinstance(find, re.Pattern)
|
||||
assert r"\1" in replace, "replacement must preserve the binding name"
|
||||
Reference in New Issue
Block a user