Files
truenas-truecloud-patch/tests/test_patch_ui.py
flan 51bf5326d9 Refuse to write a bundle whose parens we unbalanced
Commit 47cdf72 shipped a pattern that matched one closing paren and emitted one,
netting an extra `)` in the Angular bundle:

    c(2,"filterByProviders",["STORJ_IX","S3","B2"]))("required",!0)
                                                  ^^ syntax error

The TrueNAS web UI went blank. Worse, MARKER was now present in the file, so
every later run reported "already patched" and skipped -- the patch could not
heal itself, and the bundle had to be hand-restored from the .pre-truecloud-patch
backup.

patch_ui.py now compares the parenthesis balance before and after substitution and
refuses to write if it changed. A bundle we cannot patch correctly is left exactly
as it was: an unpatched UI is a missing dropdown entry, a corrupted one is a dead
web UI.

Tests cover the real regression (verbatim 47cdf72 pattern) end to end: it still
matches, the balance still shifts, main() refuses, and the file on disk is
byte-for-byte unchanged. README documents the manual recovery for anyone who
already hit it.

93 tests, ruff and shellcheck clean.
2026-07-13 14:57:16 +00:00

172 lines
6.7 KiB
Python

"""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"
class TestCorruptionGuard:
"""A bad pattern must never reach the bundle.
This is not hypothetical. Commit 47cdf72 shipped a pattern that consumed one
closing paren and emitted one, netting an extra `)`:
c(2,"filterByProviders",["STORJ_IX","S3","B2"]))("required",!0)
^^ syntax error
The web UI went blank. And because MARKER was then present in the file, every
subsequent run reported "already patched" and skipped — so the patch could not
heal itself, and the bundle had to be hand-restored from the backup.
"""
# Verbatim from 47cdf72.
BROKEN = (
re.compile(r'("filterByProviders",)\w+\(\d+,\w+,\w+\.CloudSyncProviderName\.Storj\)'),
r'\1["STORJ_IX","S3","B2"])',
)
def test_the_regression_that_blanked_the_ui_is_detectable(self):
find, replace = self.BROKEN
patched, count = find.subn(replace, REAL_25X)
assert count == 1, "it did match — that is why it got written"
assert paren_delta(patched) != paren_delta(REAL_25X), (
"the paren balance changes; this is the signal main() now refuses on"
)
def test_main_refuses_to_write_an_unbalanced_bundle(self, monkeypatch, tmp_path, capsys):
import patch_ui
bundle = tmp_path / "chunk-TEST.js"
bundle.write_text(REAL_25X, encoding="utf-8")
monkeypatch.setattr(patch_ui, "WEBUI_CANDIDATES", [str(tmp_path)])
monkeypatch.setattr(patch_ui, "_PATTERNS", [self.BROKEN])
patch_ui.main()
out = capsys.readouterr().out
assert "refusing to write" in out
# The bundle must be byte-for-byte untouched — a broken UI is far worse
# than an unpatched one.
assert bundle.read_text(encoding="utf-8") == REAL_25X
def test_a_good_pattern_still_writes(self, monkeypatch, tmp_path):
import patch_ui
bundle = tmp_path / "chunk-TEST.js"
bundle.write_text(REAL_25X, encoding="utf-8")
monkeypatch.setattr(patch_ui, "WEBUI_CANDIDATES", [str(tmp_path)])
patch_ui.main()
assert MARKER in bundle.read_text(encoding="utf-8")
assert (tmp_path / "chunk-TEST.js.pre-truecloud-patch").exists()