test(adb-off audit): name the carve-outs, drive the paths the audit names (t25)

The t24 review's four observations on the t23 ADB-off audit. No production code
changes: button.py is untouched, deliberately (see O1).

O1 — THE PAIR ADB CARVE-OUT, ACCEPTED AND NAMED. The button's press builds its own
AdbMediaSessionPoller and connects without ever reading the entry's option. I
searched for a production route that reaches the press with ADB off and found
NONE, so no gate was added:
  * `button.async_setup_entry` is the ONLY place that entity is created, and it
    creates it only while the option is on (button.py:34-35);
  * every options save runs through `NvidiaShieldOptionsFlow(OptionsFlowWithReload)`,
    which reloads the entry and re-runs that setup — so turning ADB off takes the
    button away (config_flow.py:1340-1353);
  * the press is user-initiated in any case — the same class as the already
    declared `test_connect` pairing probe.
It is now a NAMED carve-out in the audit's `CARVE_OUTS` table, with what it does,
why that is safe, and the assertions behind both. The measurement is DRIVEN rather
than quoted: the press is executed with the option OFF and ON and asserted to cost
the same 1 ctor / 1 connect / 1 disconnect, so the carve-out's substance is a test
result and not prose. A meta test checks every carve-out's named assertion really
exists, so a carve-out cannot point at a phantom. MUTATION-CHECKED: making
`async_setup_entry` add the entity unconditionally fails three of the new tests.
The intent button is explicitly NOT a carve-out (it can exist for a disabled entry
and refuses through the t23 accessor gate) — stated in the same entry.

O2 — THE AUDIT NOW EXECUTES WHAT IT NAMES. `_open_logcat_stream` and the logcat
ladder are driven both ways: two new rows in the parametrized `AUDITED_PATHS` table
(so the disabled case costs zero traffic) plus dedicated assertions — the open
RAISES "ADB is switched off" in a `pytest.raises(match=...)`, and the ladder
RETURNS at once with `_logcat_failures == []` and `_logcat_disabled is False`, i.e.
a local option is not booked as a device failure. The anti-overcorrection pin is
kept in the same class: with ADB ON, five real ladder failures DO auto-disable.
MUTATION-CHECKED: removing either t23 logcat gate fails three of these tests.

O3 — THE ROTTABLE FIGURE IS GONE. The statement_blocks bullet no longer states a
count ("currently 28" was stale within one unit of work); the band (~2%) is the
policy, the current walk lives in the header the sweep prints, and the comment now
says why no band carries a count. The relative_imports bullet lost its illustrative
figure for the same reason.

O4 — THE ONE WRITE IS NAMED AS A DEBT. The audit's module docstring now states
exactly what "nothing is cached" means: the single write a disabled path can still
make is `_app_label_failed.add(package)` in `_dump_label` — a retry DEBT, not an
answer. It is pinned by a test: with ADB off the debt is recorded while
`_app_labels` stays EMPTY and the lookup still answers None; with ADB back on the
probe runs, the label is cached and the debt is settled (the discard that the code
already does).

FAIL-CLOSED, CONFIRMED AND LOCATED. The harness change (`entry=None` now reads as
ADB off) matches production because both read the option the same way: the manager
returns "disabled" for a missing option, and `__init__.py` builds no poller for the
same missing option — asserted by two new tests (an entry with `options={}`, and a
manager with no entry at all) and by a source assertion on `__init__.py`'s own
`if not entry.options.get(CONF_ADB_ENABLED, False):`. No production path is
affected: production always constructs the manager WITH the entry, and for an entry
whose option is on nothing changed at all.

No regressions: 942 passed / 5 skipped (V1), 943 / 4 (V2) — the +19 are the new
tests. ruff clean, drift exit 0, the real-HA pattern collects and runs its 3 files.
The device side remains a scripted double: the claim proven is that nothing was
sent, not what firmware would do.
This commit is contained in:
James Mackay committed 2026-10-12 13:46:55 +01:00
1 parent de1c34777d
commit 7ed982801a
3 files changed
+410 -8

No files matched your search

+162
View File
@@ -38,6 +38,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import entity_stublib # noqa: E402
from entity_stublib import load_entity_modules # noqa: E402
from nsh.const import ( # noqa: E402
CONF_ADB_ENABLED,
CONF_REGISTERED_INTENTS,
INTENT_FIELD_ACTION,
INTENT_FIELD_ACTIVITY,
@@ -1370,3 +1371,164 @@ def test_a_truncated_capture_prefills_no_url(modules):
assert "www.exam" not in values[INTENT_FIELD_DATA]
assert values[INTENT_FIELD_ACTION] == "android.intent.action.VIEW"
assert values[INTENT_FIELD_ACTIVITY] == "com.netflix.ninja/.MainActivity"
# ── t25/O1: the Pair ADB BUTTON is a NAMED carve-out of the ADB-off audit ──
class TestThePairAdbCarveOut:
"""Why the ADB-off audit deliberately does NOT gate the Pair ADB press.
`NvidiaShieldPairAdbButton.async_press` builds its OWN poller and connects —
it never asks the entry's ADB option (the t24 review measured
ctor/connect/disconnect 1/1/1 with the option OFF, identical to option-ON).
That is accepted, not overlooked, because there is no production route to the
press with ADB off:
* `button.async_setup_entry` adds that entity ONLY for an entry whose option
is on, so a disabled entry has NO button to press (asserted below);
* every options save goes through `NvidiaShieldOptionsFlow(OptionsFlowWithReload)`,
which reloads the entry — and the reload re-runs that setup, so turning ADB
off takes the button away;
* the press itself is USER-INITIATED (somebody had to press it), which is the
same class as the `ShieldConnectionManager.test_connect` pairing probe.
If a future change ever makes that entity exist for a disabled entry, this
class fails — which is the point: the carve-out is a claim like any other.
"""
async def _setup(self, modules, options):
import importlib
entry = SimpleNamespace(
entry_id="e1", options=options, title="Shield", runtime_data=None
)
added: list = []
button = importlib.import_module("nsh.button")
await button.async_setup_entry(None, entry, added.extend)
return added
@staticmethod
def _pair_adb(added):
return [entity for entity in added if getattr(entity, "_attr_name", None) == "Pair ADB"]
async def test_no_pair_adb_button_while_adb_is_off(self, modules):
"""THE MITIGATION: a disabled entry has no button to press."""
added = await self._setup(modules, {})
assert self._pair_adb(added) == []
assert all(
not getattr(entity, "_attr_name", "").startswith("Pair")
for entity in added
)
async def test_no_pair_adb_button_with_an_explicit_off_option(self, modules):
added = await self._setup(modules, {CONF_ADB_ENABLED: False})
assert self._pair_adb(added) == []
async def test_the_button_is_back_when_adb_is_on(self, modules):
"""The anti-overcorrection pin: the entity is not gone for good (t25)."""
added = await self._setup(modules, {CONF_ADB_ENABLED: True})
pair = self._pair_adb(added)
assert len(pair) == 1
assert pair[0]._attr_unique_id.endswith("_pair_adb")
async def test_the_press_does_not_consult_the_option_and_that_is_pinned(
self, modules
):
"""THE MEASUREMENT, executed: identical traffic whichever way the option reads.
`async_press` never asks the entry's ADB option, so a press costs one
constructor, one connect and one disconnect with the option OFF — the same
as option-ON (the t24 review measured 1/1/1 both ways). This is the
carve-out's substance, so it is DRIVEN rather than quoted: if a future
change adds a gate here, this fails and the carve-out entry (and the audit
text beside it) must be updated in the same change — which is the point.
"""
import importlib
button = importlib.import_module("nsh.button")
class RecordingPoller:
constructed = 0
connects = 0
disconnects = 0
def __init__(self, host, port, key_path):
type(self).constructed += 1
self.host = host
async def connect(self):
type(self).connects += 1
return True
async def disconnect(self):
type(self).disconnects += 1
class NoReload:
async def async_reload(self, entry_id):
return True
original_poller = button.AdbMediaSessionPoller
original_key = button.ensure_adb_key
button.AdbMediaSessionPoller = RecordingPoller
button.ensure_adb_key = lambda config_dir: "/tmp/test-key.pem"
try:
measured = {}
for label, adb_enabled in (("off", False), ("on", True)):
RecordingPoller.constructed = 0
RecordingPoller.connects = 0
RecordingPoller.disconnects = 0
entry = SimpleNamespace(
entry_id="e1",
options={
CONF_ADB_ENABLED: adb_enabled,
"host": "shield.invalid",
},
title="Shield",
runtime_data=None,
)
entity = button.NvidiaShieldPairAdbButton(None, entry)
entity.hass = SimpleNamespace(
config=SimpleNamespace(config_dir="/tmp"),
async_add_executor_job=FakeHass.async_add_executor_job,
config_entries=NoReload(),
)
await entity.async_press()
measured[label] = (
RecordingPoller.constructed,
RecordingPoller.connects,
RecordingPoller.disconnects,
)
finally:
button.AdbMediaSessionPoller = original_poller
button.ensure_adb_key = original_key
assert measured["off"] == (1, 1, 1), measured["off"]
assert measured["on"] == (1, 1, 1), measured["on"]
assert measured["off"] == measured["on"], (
"the option does not gate this path — that is the named carve-out"
)
async def test_the_reload_that_removes_it_is_the_options_flow(self):
"""The second half of the mitigation, pinned where it lives."""
source = (
Path(__file__).resolve().parent.parent
/ "custom_components"
/ "nvidia_shield"
/ "config_flow.py"
).read_text(encoding="utf-8")
assert "OptionsFlowWithReload" in source, (
"an options save must reload the entry (which re-runs button setup)"
)
button_source = (
Path(__file__).resolve().parent.parent
/ "custom_components"
/ "nvidia_shield"
/ "button.py"
).read_text(encoding="utf-8")
assert "if entry.options.get(CONF_ADB_ENABLED, False):" in button_source, (
"the Pair ADB entity is created only while the option is on"
)