from __future__ import annotations import json import ssl import sys from pathlib import Path from typing import Any from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import streamlit_browser_evidence as evidence SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value") SURFACE_STYLE = { "backgroundImage": "linear-gradient(rgb(1, 2, 3), rgb(4, 5, 6))", "backgroundColor": "rgb(255, 255, 255)", "visibility": "visible", "display": "block", "opacity": "1", "rendered": True, } def surface_records() -> dict[str, dict[str, object]]: return { "stAppViewContainer": dict(SURFACE_STYLE), "stSidebar": dict(SURFACE_STYLE), } def test_redact_secrets_removes_values_from_nested_manifest() -> None: payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]} redacted = evidence.redact_secrets(payload, SECRET_VALUES) serialized = json.dumps(redacted) assert all(secret not in serialized for secret in SECRET_VALUES) assert redacted["nested"][0] == "[REDACTED]" def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None: with pytest.raises(SystemExit, match="fixture.sqlite3"): evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3") def test_validate_transition_manifest_requires_observations() -> None: manifest = { "mode": "transition", "success": True, "observations": {"frames": [], "navigation_events": []}, } with pytest.raises(ValueError, match="frames"): evidence.validate_transition_manifest(manifest) def complete_manifest() -> dict[str, Any]: return { "mode": "transition", "success": True, "observations": { "frames": [ { "at_ms": 0, "selected_menu": "요약", "body_text_hash": "hash", "loading": False, "prior_body_visible": True, "surfaces": surface_records(), }, { "at_ms": 100, "selected_menu": "분류 규칙", "body_text_hash": "hash2", "loading": True, "prior_body_visible": False, "surfaces": surface_records(), }, ], "document_navigation_events": [], "history_events": [{"type": "pushState", "url": "/?page=rules"}], "stable_shell": { "document_marker": {"pre": "doc-1", "post": "doc-1"}, "sidebar_marker": {"pre": "side-1", "post": "side-1"}, "background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"}, "surfaces": {"pre": surface_records(), "post": surface_records()}, }, "loading_observed": True, "prior_body_removed": True, "target_page": "분류 규칙", "final_agreement": True, }, "runtime": { "viewport": {"width": 1440, "height": 1400}, "interval_ms": 50, "host_architecture": "aarch64", "browser_user_agent": "Synthetic Browser", }, "source_revision": evidence.current_git_revision(), "source_revision_kind": "git-head-base", "source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")), "source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")), "evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"], "command": {"exit_code": 0, "teardown": "complete"}, "limitations": [], } def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None: evidence.validate_transition_manifest(complete_manifest()) def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None: response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})() with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen: evidence.wait_for_http("https://example.test") assert urlopen.call_args.kwargs["context"] is None fixture_context = ssl._create_unverified_context() with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen: evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context) assert urlopen.call_args.kwargs["context"] is fixture_context def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None: manifest = complete_manifest() manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}] with pytest.raises(ValueError, match="document navigation"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None: no_loading = complete_manifest() no_loading["observations"]["loading_observed"] = False with pytest.raises(ValueError, match="loading"): evidence.validate_transition_manifest(no_loading) stale_body = complete_manifest() stale_body["observations"]["frames"][1]["prior_body_visible"] = True with pytest.raises(ValueError, match="prior body"): evidence.validate_transition_manifest(stale_body) def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None: manifest = complete_manifest() manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"} with pytest.raises(ValueError, match="shell"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None: manifest = complete_manifest() manifest["observations"]["final_agreement"] = False with pytest.raises(ValueError, match="agreement"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None: manifest = complete_manifest() manifest["command"] = {"exit_code": 0, "teardown": "running"} with pytest.raises(ValueError, match="teardown"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None: manifest = complete_manifest() manifest.pop("source_files") with pytest.raises(ValueError, match="source files"): evidence.validate_transition_manifest(manifest) manifest = complete_manifest() manifest["runtime"].pop("browser_user_agent") with pytest.raises(ValueError, match="runtime identity"): evidence.validate_transition_manifest(manifest) manifest = complete_manifest() manifest["source_files"]["dashboard/app.py"] = "0" * 64 with pytest.raises(ValueError, match="source file digests"): evidence.validate_transition_manifest(manifest) def test_transition_manifest_rejects_secret_text() -> None: manifest = {"mode": "transition", "success": False, "error": "synthetic-password"} with pytest.raises(ValueError, match="secret"): evidence.validate_transition_manifest(manifest, SECRET_VALUES) def test_validate_transition_manifest_requires_surface_style_evidence_on_every_frame() -> None: manifest = complete_manifest() manifest["observations"]["frames"][1]["surfaces"].pop("stSidebar") with pytest.raises(ValueError, match="surface"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_requires_stable_surface_style_evidence() -> None: manifest = complete_manifest() manifest["observations"]["stable_shell"]["surfaces"]["post"]["stSidebar"]["opacity"] = "0" with pytest.raises(ValueError, match="surface"): evidence.validate_transition_manifest(manifest) @pytest.mark.parametrize( ("field", "value"), ( ("backgroundImage", "none"), ("backgroundColor", ""), ("visibility", "hidden"), ("display", "none"), ("opacity", "0"), ("opacity", "NaN"), ("rendered", False), ), ) def test_validate_transition_manifest_rejects_non_visible_surface_styles(field: str, value: object) -> None: manifest = complete_manifest() observations = manifest["observations"] for frame in observations["frames"]: frame["surfaces"]["stAppViewContainer"][field] = value observations["stable_shell"]["surfaces"]["pre"]["stAppViewContainer"][field] = value observations["stable_shell"]["surfaces"]["post"]["stAppViewContainer"][field] = value with pytest.raises(ValueError, match="surface"): evidence.validate_transition_manifest(manifest) def test_validate_transition_manifest_rejects_missing_stable_shell_markers() -> None: manifest = complete_manifest() manifest["observations"]["stable_shell"]["document_marker"] = {"pre": None, "post": None} with pytest.raises(ValueError, match="shell"): evidence.validate_transition_manifest(manifest)