From 29a4020bc5791c17c0ee8e78fd03b054bcf56909 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 4 Aug 2026 14:46:48 +0300 Subject: [PATCH] fix(security): an unhashed audit chain no longer reports as verified (#1984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/audit-log/verify` answered `valid: true, checked: 0` for an audit log in which no entry carried a hash. Found by probing a live instance: 1,162 real audit entries, zero hashes, and a green "✓ Valid · 0 entries" badge in the UI — the tick sitting directly beside the zero that contradicts it. `valid: true` was returned for three materially different states — chain verified, range empty, and *no integrity data exists at all*. The last is the DEFAULT for every install that never enabled hashing, so the endpoint's verdict was effectively independent of its input: the class `docs/memory/learnings.md` already names from #1941. Skipping an unhashed entry is correct on its own — a chain enabled midway legitimately has an unhashed prefix. The defect was the aggregate verdict when EVERY entry was skipped. `valid` is now tri-state: True verified, False mismatch, **None unverifiable**. `None` rather than `False` deliberately — `False` claims tampering, which is an equally wrong and considerably louder lie, and a caller doing a plain truthiness test degrades to "not verified", the safe direction. `status` carries the precise verdict (`verified`, `verified_partial`, `tampered`, `unverifiable`, `empty_range`) and `skipped_unhashed` makes a late-enabled chain's permanent unhashed prefix visible instead of silently averaged over. The response also reports `hash_chain_enabled`, which is what made `checked: 0` uninterpretable. Trinity already does this correctly one endpoint over: `/api/monitoring/status` returns `enabled: false` beside a stale summary so the reader can interpret it. **The frontend had to change too.** The store did `data.valid ? 'valid' : 'invalid'`, so a backend-only fix would have turned a false green into a false RED — a tamper alarm for a log that is merely unhashed. It now branches on the tri-state and renders an amber "Unverifiable" badge. The store also had its own vacuous path, asserting `verifyState='valid'` for an empty list without calling the API at all; that is gone. tests/unit/test_1984_audit_verify_unverifiable.py — 16 checks, 15 of which fail against the pre-fix tree. Closes #1984 --- src/backend/models.py | 17 +- .../services/platform_audit_service.py | 67 +++- src/frontend/src/stores/auditLog.js | 24 +- src/frontend/src/views/enterprise/Audit.vue | 19 +- tests/registry.json | 13 + .../test_1984_audit_verify_unverifiable.py | 296 ++++++++++++++++++ 6 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_1984_audit_verify_unverifiable.py diff --git a/src/backend/models.py b/src/backend/models.py index c6786a86b..750346e3a 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1970,8 +1970,21 @@ class AuditCalendarResponse(BaseModel): class AuditVerifyResponse(BaseModel): """Hash chain verification result.""" - valid: bool - checked: int + # TRI-STATE (#1984). True = verified intact, False = mismatch/tampering, + # None = UNVERIFIABLE (nothing in the range carried a hash). It was a plain + # `bool`, so "no integrity data exists" was indistinguishable from + # "verified" — and that is the default state of every install which never + # enabled hashing. + valid: Optional[bool] = None + # verified | verified_partial | tampered | unverifiable | empty_range + status: str = "unverifiable" + checked: int = 0 + # Entries skipped for carrying no hash. Non-zero alongside + # `verified_partial` marks the permanent unhashed prefix of a chain that + # was enabled midway. + skipped_unhashed: int = 0 + total_in_range: int = 0 + hash_chain_enabled: bool = False first_invalid_id: Optional[int] = None diff --git a/src/backend/services/platform_audit_service.py b/src/backend/services/platform_audit_service.py index 7f4c83d75..b1fcbfcf0 100644 --- a/src/backend/services/platform_audit_service.py +++ b/src/backend/services/platform_audit_service.py @@ -203,36 +203,87 @@ def enable_hash_chain(self, enabled: bool = True) -> None: async def verify_chain(self, start_id: int, end_id: int) -> Dict[str, Any]: """Verify hash chain integrity between two row IDs (inclusive). + `valid` is deliberately TRI-STATE (#1984): + + * ``True`` — entries were hashed and the chain checks out + * ``False`` — a hash mismatch: tampering or corruption + * ``None`` — **unverifiable**: there was nothing to check + + The third state is the fix. Skipping an unhashed entry is right on its + own (a chain enabled midway legitimately has an unhashed prefix), but + when EVERY entry was skipped the old code still answered ``valid: True`` + — so an install that never enabled hashing, which is the default, + reported its audit log verified-intact across thousands of rows. One + answer for three different states: verified, empty, and "no integrity + data exists". An operator asking "was this tampered with?" during an + incident got a green tick meaning "unanswerable". + + ``None`` rather than ``False``: ``False`` claims tampering, which is an + equally wrong and considerably louder lie. A caller doing a plain + truthiness test degrades to "not verified" — the safe direction. + Returns: - {"valid": bool, "checked": int, "first_invalid_id": int | None} + {"valid": bool | None, "status": str, "checked": int, + "skipped_unhashed": int, "total_in_range": int, + "hash_chain_enabled": bool, "first_invalid_id": int | None} """ entries = db.get_audit_entries_range(start_id, end_id) + base: Dict[str, Any] = { + "checked": 0, + "skipped_unhashed": 0, + "total_in_range": len(entries), + "hash_chain_enabled": self._hash_chain_enabled, + "first_invalid_id": None, + } + if not entries: - return {"valid": True, "checked": 0, "first_invalid_id": None} + # Distinct from "rows exist but none are hashed": the caller asked + # about a range holding nothing, which is not a statement about + # integrity in either direction. + return {**base, "valid": None, "status": "empty_range"} checked = 0 + skipped = 0 for i, entry in enumerate(entries): if not entry.get("entry_hash"): - # Entry was written before hash chain was enabled — skip + # Written before hash chain was enabled — skipped, and now + # COUNTED, so the verdict below can tell "some" from "none". + skipped += 1 continue expected = self._compute_hash(entry) if entry["entry_hash"] != expected: return { - "valid": False, - "checked": checked + 1, + **base, "valid": False, "status": "tampered", + "checked": checked + 1, "skipped_unhashed": skipped, "first_invalid_id": entry["id"], } if i > 0 and entry.get("previous_hash"): prev = entries[i - 1] if prev.get("entry_hash") and entry["previous_hash"] != prev["entry_hash"]: return { - "valid": False, - "checked": checked + 1, + **base, "valid": False, "status": "tampered", + "checked": checked + 1, "skipped_unhashed": skipped, "first_invalid_id": entry["id"], } checked += 1 - return {"valid": True, "checked": checked, "first_invalid_id": None} + if checked == 0: + # Rows exist; none carry a hash. THE reported bug (#1984). + return { + **base, "valid": None, "status": "unverifiable", + "skipped_unhashed": skipped, + } + + return { + **base, + "valid": True, + # Named apart so a partially-hashed range cannot pass for a fully + # verified one — the unhashed prefix is permanent on any install + # that enabled hashing later. + "status": "verified_partial" if skipped else "verified", + "checked": checked, + "skipped_unhashed": skipped, + } @staticmethod def _compute_hash(entry: Dict[str, Any]) -> str: diff --git a/src/frontend/src/stores/auditLog.js b/src/frontend/src/stores/auditLog.js index 1152b85a1..389216af1 100644 --- a/src/frontend/src/stores/auditLog.js +++ b/src/frontend/src/stores/auditLog.js @@ -61,7 +61,11 @@ export const useAuditLogStore = defineStore('auditLog', { // #941 v2 — dashboard expansion stats: null, // { total, by_event_type: {...}, by_actor_type: {...} } statsLoading: false, - verifyState: 'idle', // idle | verifying | valid | invalid | error + // #1984: 'unverifiable' is a REQUIRED state, not a nicety. The backend + // answers valid=null when nothing in range was hashed; without this the + // binary `data.valid ? 'valid' : 'invalid'` below rendered that as a + // green tick (before) or a tamper alarm (after) — both wrong. + verifyState: 'idle', // idle | verifying | valid | unverifiable | invalid | error verifyResult: null, // { checked, first_invalid_id?, range?: [start, end] } activePreset: '24h', // '1h' | '24h' | '7d' | '30d' | 'all' | 'custom' exporting: false, @@ -401,8 +405,10 @@ export const useAuditLogStore = defineStore('auditLog', { const authStore = useAuthStore() if (!authStore.isAuthenticated) return if (this.entries.length === 0) { - this.verifyState = 'valid' - this.verifyResult = { checked: 0, range: null } + // #1984: was 'valid' with checked:0 — the same vacuous affirmation the + // backend made, asserted client-side without even asking. + this.verifyState = 'unverifiable' + this.verifyResult = { checked: 0, status: 'empty_range', range: null } return } const ids = this.entries.map((e) => Number(e.id)).filter((n) => !isNaN(n)) @@ -426,10 +432,20 @@ export const useAuditLogStore = defineStore('auditLog', { const data = r.data || {} this.verifyResult = { checked: Number(data.checked) || 0, + skipped_unhashed: Number(data.skipped_unhashed) || 0, + total_in_range: Number(data.total_in_range) || 0, + hash_chain_enabled: Boolean(data.hash_chain_enabled), + status: data.status || null, first_invalid_id: data.first_invalid_id ?? null, range: [startId, endId], } - this.verifyState = data.valid ? 'valid' : 'invalid' + // Tri-state, NOT truthiness. `valid === null` means "nothing was + // hashed, so integrity is unknowable" — reporting that as either + // verified or tampered is a different wrong answer (#1984). + this.verifyState = + data.valid === true ? 'valid' + : data.valid === false ? 'invalid' + : 'unverifiable' } catch (e) { this.verifyState = 'error' this.verifyResult = null diff --git a/src/frontend/src/views/enterprise/Audit.vue b/src/frontend/src/views/enterprise/Audit.vue index 63da97906..42bdfc24c 100644 --- a/src/frontend/src/views/enterprise/Audit.vue +++ b/src/frontend/src/views/enterprise/Audit.vue @@ -381,6 +381,8 @@ const detailsJson = computed(() => { store.verifyState === 'valid', 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200': store.verifyState === 'invalid', + 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200': + store.verifyState === 'unverifiable', 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200': store.verifyState === 'error', }" @@ -388,7 +390,22 @@ const detailsJson = computed(() => { Hash chain · not verified Verifying… - ✓ Valid · {{ store.verifyResult?.checked || 0 }} entries + ✓ Valid · {{ store.verifyResult?.checked || 0 }} entries + + + + + ✗ Tamper detected · first invalid id #{{ store.verifyResult?.first_invalid_id }} diff --git a/tests/registry.json b/tests/registry.json index d6903b5a7..1b7298f8b 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1442,6 +1442,19 @@ "security" ], "description": "Hardened parsing of author-controlled YAML (ent#314). template.yaml was on bare yaml.safe_load, leaving alias amplification (measured 416 B -> 110 MB at json.dumps time, parse itself 0.0011 s, so an input-size cap cannot close it) and silent last-wins duplicate keys, which let a template show one credentials: block to a human and declare another to Trinity. Reachable by any creator-role user via a public repo (ent#123 tokenless) since _build_template runs unfenced inside get_all_templates(). Covers: the fixture itself still amplifies under bare safe_load (so the suite cannot pass vacuously); level 4/5/6 bombs rejected under both policies; a small honest anchor STILL parses under BUDGET (the #1932 lesson \u2014 a guard that rejects the legitimate document is an outage that reads as hardened); REJECT refuses one alias and also gates at the scanner (skill_packaging's copy had both hooks); the issue's exact duplicate-credentials case and a nested duplicate; size cap; reject-not-truncate; ManifestError still a subclass so routers/systems.py's named 400 is unchanged and the manifest's published codes survive; and two consolidation guards \u2014 no module may grow a fourth SafeLoader subclass, and all four author-controlled readers must call the shared loader with no bare safe_load left. The four product-level guards fail against pre-fix code." + }, + { + "file": "unit/test_1984_audit_verify_unverifiable.py", + "feature": "#1984", + "added": "2026-08-04", + "categories": [ + "backend", + "frontend", + "unit", + "security", + "audit" + ], + "description": "An unhashed audit chain must not report as verified (#1984). POST /api/audit-log/verify answered valid:true, checked:0 for a log in which no entry carried a hash - found by probing a live instance: 1,162 real audit entries, zero hashes, green tick in the UI. valid:true was returned for three materially different states (chain verified, range empty, no integrity data at all) and the last is the DEFAULT for every install that never enabled hashing, so the verdict was independent of the input - the class learnings.md already names from #1941. Skipping an unhashed entry is correct on its own (a chain enabled midway legitimately has an unhashed prefix); the defect was the aggregate verdict when EVERY entry was skipped. valid is now tri-state: None rather than False, because False claims tampering - an equally wrong and much louder lie - while a caller doing a truthiness test degrades to 'not verified', the safe direction. Covers: rows-with-no-hashes yields unverifiable not valid; unverifiable is falsy but is NOT False and does not set first_invalid_id; empty_range is its own state (both it and no-hashes report checked:0, which was previously the only signal and made them indistinguishable); an intact chain still verifies and tampering is still detected (no regression); a partially hashed range is named verified_partial with skipped_unhashed exposed, so the permanent unhashed prefix of a late-enabled chain cannot pass for full coverage; the response reports hash_chain_enabled at all, mirroring what monitoring/status already does with enabled:false beside a stale summary; and the response model accepts a null valid (it was `bool`, which would have 500'd the fix at the router). The FRONTEND is covered in the same file deliberately: the store did `data.valid ? 'valid' : 'invalid'`, so a backend-only fix would have turned a false green into a false RED - a tamper alarm for a merely-unhashed log. Pins the tri-state branch, the new store state, the removal of the store's own vacuous 'valid' for an empty list (asserted client-side without even calling the API), and an amber badge so the state does not fall through to 'Verify failed'. Source assertions strip // comments before matching - the inverse of the #1941 trap, since this fix's own comments quote the old code while explaining why it went." } ] } diff --git a/tests/unit/test_1984_audit_verify_unverifiable.py b/tests/unit/test_1984_audit_verify_unverifiable.py new file mode 100644 index 000000000..327290f6c --- /dev/null +++ b/tests/unit/test_1984_audit_verify_unverifiable.py @@ -0,0 +1,296 @@ +"""#1984 — an unhashed audit chain must not report as verified. + +`POST /api/audit-log/verify` answered `valid: true, checked: 0` for a log in +which no entry carried a hash. Found by probing a live instance: 1,162 real +audit entries, zero hashes, green tick. `valid: true` was returned for three +materially different states — chain verified, range empty, and *no integrity +data exists at all* — and the last is the DEFAULT for every install that never +enabled hashing. + +Skipping an unhashed entry is correct on its own; a chain enabled midway +legitimately has an unhashed prefix. The defect was the aggregate verdict when +**every** entry was skipped. + +`valid` is now tri-state. `None`, not `False`: `False` claims tampering, which +is an equally wrong and much louder lie, and a caller doing a truthiness test +degrades to "not verified" — the safe direction. + +The frontend is covered here too, because a backend-only fix would have turned +a false green into a false *red*: the store did `data.valid ? 'valid' : +'invalid'`, so `null` would have rendered as "✗ Tamper detected". +""" +from __future__ import annotations + +import asyncio +import re +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_BACKEND = _REPO / "src" / "backend" +_BACKEND_STR = str(_BACKEND) +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + + +@pytest.fixture +def svc(): + try: + from services.platform_audit_service import platform_audit_service + except ImportError: # pragma: no cover - backend venv required + pytest.skip("backend venv required") + return platform_audit_service + + +def _entries(svc_mod, monkeypatch, rows): + import services.platform_audit_service as mod + + monkeypatch.setattr(mod.db, "get_audit_entries_range", + lambda s, e: rows, raising=False) + + +def _row(id_: int, **over) -> dict: + """A complete audit row. + + `_compute_hash` reads event_id/event_type/event_action/timestamp directly + (KeyError, not .get), so a partial fixture fails inside the hasher and looks + like a code bug. Build the full shape once. + """ + row = { + "id": id_, + "event_id": f"evt-{id_}", + "event_type": "configuration", + "event_action": "settings_change", + "actor_id": "1", + "target_id": None, + "timestamp": f"2026-08-04T00:00:{id_:02d}Z", + "details": None, + "entry_hash": None, + "previous_hash": None, + } + row.update(over) + return row + + +def _hashed(svc_mod, entry: dict) -> dict: + """Give an entry the hash the verifier will recompute for it.""" + out = dict(entry) + out["entry_hash"] = svc_mod._compute_hash(out) + return out + + +# --------------------------------------------------------------------------- +# THE bug. +# --------------------------------------------------------------------------- + + +def test_rows_with_no_hashes_are_unverifiable_not_valid(svc, monkeypatch): + """The reported case, reproduced from the live shape: many rows, zero + hashes. The old code answered `valid: True, checked: 0`.""" + _entries(svc, monkeypatch, [ + _row(i) for i in range(1, 51) + ]) + + out = asyncio.run(svc.verify_chain(1, 50)) + + assert out["valid"] is None, ( + "an audit log with no integrity data reported a verdict about its " + "integrity (#1984)" + ) + assert out["status"] == "unverifiable" + assert out["checked"] == 0 + assert out["skipped_unhashed"] == 50 + assert out["total_in_range"] == 50 + + +def test_unverifiable_is_not_truthy(svc, monkeypatch): + """A caller doing `if result["valid"]:` must NOT treat this as verified. + That truthiness test is exactly what the frontend did.""" + _entries(svc, monkeypatch, [ + _row(1), + ]) + assert not asyncio.run(svc.verify_chain(1, 1))["valid"] + + +def test_unverifiable_does_not_claim_tampering(svc, monkeypatch): + """`False` would be a louder wrong answer than the original `True` — it + would page someone about a breach that did not happen.""" + _entries(svc, monkeypatch, [ + _row(1), + ]) + out = asyncio.run(svc.verify_chain(1, 1)) + assert out["valid"] is not False + assert out["status"] != "tampered" + assert out["first_invalid_id"] is None + + +# --------------------------------------------------------------------------- +# The states that must stay distinguishable (AC #2). +# --------------------------------------------------------------------------- + + +def test_empty_range_is_its_own_state(svc, monkeypatch): + """"No such rows" is not a statement about integrity in either direction, + and must not be conflated with "rows exist but none are hashed" — both used + to be `checked: 0`.""" + _entries(svc, monkeypatch, []) + out = asyncio.run(svc.verify_chain(1, 10)) + assert out["status"] == "empty_range" + assert out["valid"] is None + assert out["total_in_range"] == 0 + + +def test_a_genuinely_intact_chain_still_verifies(svc, monkeypatch): + """No regression: the whole point is that a real verification still passes, + or the fix is just a different broken answer.""" + import services.platform_audit_service as mod + + e1 = _hashed(mod.platform_audit_service, _row(1)) + e2 = _hashed(mod.platform_audit_service, + _row(2, previous_hash=e1["entry_hash"])) + _entries(svc, monkeypatch, [e1, e2]) + + out = asyncio.run(svc.verify_chain(1, 2)) + assert out["valid"] is True + assert out["status"] == "verified" + assert out["checked"] == 2 + assert out["skipped_unhashed"] == 0 + + +def test_tampering_is_still_detected(svc, monkeypatch): + _entries(svc, monkeypatch, [ + _row(1, entry_hash="deadbeef"), + ]) + out = asyncio.run(svc.verify_chain(1, 1)) + assert out["valid"] is False + assert out["status"] == "tampered" + assert out["first_invalid_id"] == 1 + + +def test_a_partially_hashed_range_is_named_apart(svc, monkeypatch): + """An install that enables hashing later carries a permanent unhashed + prefix. Reporting that as a clean `verified` would silently average over + the boundary — the reader cannot tell how much was actually covered.""" + import services.platform_audit_service as mod + + hashed = _hashed(mod.platform_audit_service, _row(3)) + _entries(svc, monkeypatch, [_row(1), _row(2), hashed]) + + out = asyncio.run(svc.verify_chain(1, 3)) + assert out["valid"] is True, "the hashed portion did verify" + assert out["status"] == "verified_partial" + assert out["checked"] == 1 + assert out["skipped_unhashed"] == 2, "the uncovered prefix must be visible" + + +@pytest.mark.parametrize( + ("rows", "expected_status"), + [ + pytest.param([], "empty_range", id="empty"), + pytest.param( + [_row(1)], "unverifiable", id="no-hashes", + ), + ], +) +def test_the_two_zero_checked_states_are_distinguishable(svc, monkeypatch, rows, + expected_status): + """Both report `checked: 0`. Before this change that was the ONLY signal, + so they were indistinguishable — which is AC #2.""" + _entries(svc, monkeypatch, rows) + out = asyncio.run(svc.verify_chain(1, 5)) + assert out["checked"] == 0 + assert out["status"] == expected_status + + +def test_response_reports_whether_hashing_is_even_on(svc, monkeypatch): + """`monitoring/status` already does this — it returns `enabled: false` + beside a stale summary so the reader can interpret it. Verify had no + equivalent, which is why `checked: 0` was uninterpretable.""" + _entries(svc, monkeypatch, []) + assert "hash_chain_enabled" in asyncio.run(svc.verify_chain(1, 1)) + + +# --------------------------------------------------------------------------- +# The response model must be able to carry the third state. +# --------------------------------------------------------------------------- + + +def test_the_response_model_accepts_a_null_valid(): + """`valid: bool` would coerce `None` to a validation error at the router + and turn the fix into a 500.""" + from models import AuditVerifyResponse + + m = AuditVerifyResponse(valid=None, status="unverifiable", checked=0, + skipped_unhashed=3, total_in_range=3, + hash_chain_enabled=False) + assert m.valid is None + assert m.model_dump()["status"] == "unverifiable" + + +def test_router_passes_the_whole_result_through(): + """A field the service computes but the router drops is invisible to every + caller — the failure mode this endpoint already had.""" + src = (_BACKEND / "routers" / "audit_log.py").read_text(encoding="utf-8") + assert "AuditVerifyResponse(**result)" in src + + +# --------------------------------------------------------------------------- +# Frontend — a backend-only fix turns a false green into a false red. +# --------------------------------------------------------------------------- + + +def _store_src() -> str: + """Store source with `//` comment lines stripped. + + Deliberate, and the inverse of the trap `test_1941_nightly_merge_depth.py` + records: there a comment quoting the old code made a naive search PASS + vacuously. Here this fix's own comments quote the old + `data.valid ? 'valid' : 'invalid'` while explaining why it went — and an + unstripped search FAILS on the explanation. Either way, assert against what + executes, not against prose about it. + """ + raw = (_REPO / "src" / "frontend" / "src" / "stores" / "auditLog.js").read_text( + encoding="utf-8" + ) + return "\n".join( + line for line in raw.splitlines() if not line.strip().startswith("//") + ) + + +def test_store_branches_on_the_tri_state_not_truthiness(): + """`data.valid ? 'valid' : 'invalid'` maps `null` to 'invalid' — a tamper + alarm for a log that is merely unhashed.""" + src = _store_src() + assert "data.valid === true" in src and "data.valid === false" in src + assert not re.search(r"data\.valid\s*\?\s*'valid'\s*:\s*'invalid'", src), ( + "the store still collapses the tri-state into a boolean (#1984)" + ) + + +def test_store_declares_the_unverifiable_state(): + assert "'unverifiable'" in _store_src() + + +def test_store_no_longer_asserts_valid_for_an_empty_list(): + """It set `verifyState='valid', checked:0` without calling the API at all — + the same vacuous affirmation, client-side.""" + src = _store_src() + empty_branch = src[src.index("if (this.entries.length === 0)"):][:400] + assert "'unverifiable'" in empty_branch + assert "verifyState = 'valid'" not in empty_branch + + +def test_the_badge_renders_the_third_state(): + """Without this the new state falls through to the `v-else` "Verify failed" + branch, which reads as a broken request rather than an honest answer.""" + vue = ( + _REPO / "src" / "frontend" / "src" / "views" / "enterprise" / "Audit.vue" + ).read_text(encoding="utf-8") + assert "store.verifyState === 'unverifiable'" in vue + assert "Unverifiable" in vue + # And it must not be styled as success or as tamper. + badge = vue[vue.index("verifyState === 'unverifiable'"):][:400] + assert "amber" in badge or "yellow" in badge