diff --git a/docs/memory/feature-flows/audit-trail.md b/docs/memory/feature-flows/audit-trail.md index 76cfc2e09..6ce36a63e 100644 --- a/docs/memory/feature-flows/audit-trail.md +++ b/docs/memory/feature-flows/audit-trail.md @@ -469,10 +469,14 @@ When enabled: Verify with `POST /api/audit-log/verify?start_id=1&end_id=100`: ```json -{"valid": true, "checked": 100, "first_invalid_id": null} +{"valid": true, "status": "verified", "checked": 100, "skipped_unhashed": 0, + "total_in_range": 100, "hash_chain_enabled": true, "first_invalid_id": null} ``` -Entries written before hash chain was enabled are skipped during verification. +Entries written before hash chain was enabled are skipped during verification +(surfaced as `skipped_unhashed`, `status: "verified_partial"`). An all-unhashed +range returns `valid: null, status: "unverifiable"`; an empty range returns +`valid: null, status: "empty_range"` — never a vacuous `valid: true` (#1984). ### Export diff --git a/src/backend/models.py b/src/backend/models.py index c7a4940ff..27a3b50ff 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1981,8 +1981,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 b6b831a07..fcb671b07 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1492,6 +1492,19 @@ "credentials" ], "description": "A global GitHub PAT rotation must actually reach running agents (#1967). The #211 auto-propagation reported success while updating nothing, for two independent reasons, both caused by the global path and the per-agent path (#1264) having drifted apart. (1) The eligibility gate was .env-shaped: _propagate_to_agent passed add_if_missing=False, so an agent with no /home/developer/.env - which is EVERY agent provisioned from a GitHub template, since those ship .env.example - returned skipped_no_pat; on such a fleet 100% skipped and the endpoint still answered success:true. (2) .env is not where git authenticates from: clones are created as https://oauth2:@github.com/... and that URL is persisted in .git/config on the workspace volume, so rewriting .env changes nothing for the running git process - only git_service.update_remote_pat restores fetch/push before a restart, and only the per-agent path called it. Observed: agents on a revoked token for 11-13 days with nothing in the UI saying so. Covers both halves: an agent with a git config but no .env is now updated (add_if_missing derived per agent, not hard-coded), and the live remote is re-templated with remotes_updated reported SEPARATELY from updated (updated alone overstates the fix - an agent whose .env was rewritten but whose remote was not is still broken for git until restart, which would repeat this issue's own mistake of reporting success for a partial effect). Critically also pins what the fix must NOT be: the issue suggests add_if_missing=True unconditionally, which would spray the global token into every running container including agents that never touched GitHub - the module docstring says the original gate existed to prevent exactly that - so eligibility moved to the git config and a non-GitHub agent keeps the conservative behaviour, with an explicit no-regression test that an agent carrying GITHUB_PAT for the gh CLI with no Trinity-managed repo is STILL rotated (the new gate is a union with the old, not a replacement). Plus: per-agent-PAT agents still skipped and never contacted, stopped agents neither counted nor contacted, one failing agent does not stop the fleet, a git-config read that raises degrades to the conservative gate instead of taking down the rotation, a zero-reach rotation WARNs in the platform log (the failure was silent for weeks; a Settings panel nobody is watching during an incident is not sufficient), and two structural guards that both paths share one _apply_pat_to_agent body and that add_if_missing=False is not reintroduced - the drift IS the bug." + }, + { + "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/test_audit_log_unit.py b/tests/test_audit_log_unit.py index 097492df5..13d2869ed 100644 --- a/tests/test_audit_log_unit.py +++ b/tests/test_audit_log_unit.py @@ -996,10 +996,11 @@ def test_verify_chain_valid(audit_service, audit_ops): def test_verify_chain_empty_range(audit_service, audit_ops): - """verify_chain on empty range returns valid=True, checked=0.""" + """verify_chain on an empty range is unverifiable (#1984), not vacuously valid.""" service, _, _ = audit_service result = asyncio.run(service.verify_chain(999999, 999999)) - assert result["valid"] is True + assert result["valid"] is None + assert result["status"] == "empty_range" assert result["checked"] == 0 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