fix(dashboard): keep shell during menu transitions
Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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, object]:
|
||||
return {
|
||||
"mode": "transition",
|
||||
"success": True,
|
||||
"observations": {
|
||||
"frames": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"selected_menu": "요약",
|
||||
"body_text_hash": "hash",
|
||||
"loading": False,
|
||||
"prior_body_visible": True,
|
||||
},
|
||||
{
|
||||
"at_ms": 100,
|
||||
"selected_menu": "분류 규칙",
|
||||
"body_text_hash": "hash2",
|
||||
"loading": True,
|
||||
"prior_body_visible": False,
|
||||
},
|
||||
],
|
||||
"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)"},
|
||||
},
|
||||
"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)
|
||||
Reference in New Issue
Block a user