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:
flan
2026-07-13 14:37:43 +00:00
parent 8421a34d8d
commit 8a2028bfa7
2 changed files with 127 additions and 4 deletions
+14 -4
View File
@@ -9,8 +9,8 @@ in the minified JS in one of two forms depending on TrueNAS / Angular version:
TrueNAS 24.x (static inline array): TrueNAS 24.x (static inline array):
"filterByProviders",["STORJ_IX"] "filterByProviders",["STORJ_IX"]
TrueNAS 25.x+ (Angular pureFunction binding): TrueNAS 25.x+ (Angular pureFunction binding, inside a chained property call):
"filterByProviders",pe(115,Rn,i.CloudSyncProviderName.Storj) 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 Both are replaced so the dropdown includes S3 and B2. The file is backed up
before modification so uninstall.sh can restore it. before modification so uninstall.sh can restore it.
@@ -33,11 +33,21 @@ WEBUI_CANDIDATES = [
# Patterns tried in order; the first match wins. # Patterns tried in order; the first match wins.
# Each entry is (compiled_regex, replacement_string). # Each entry is (compiled_regex, replacement_string).
_PATTERNS = [ _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\)\)'), (re.compile(r'("filterByProviders",)\w+\(\d+,\w+,\w+\.CloudSyncProviderName\.Storj\)\)'),
r'\1["STORJ_IX","S3","B2"])'), r'\1["STORJ_IX","S3","B2"])'),
# TrueNAS 24.x and earlier: static inline array. # TrueNAS 24.x and earlier: static inline array.
(re.compile(r'("filterByProviders",)\["STORJ_IX"\]'), (re.compile(r'("filterByProviders",)\["STORJ_IX"\]'),
r'\1["STORJ_IX","S3","B2"]'), r'\1["STORJ_IX","S3","B2"]'),
+113
View File
@@ -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"