230 lines
9.1 KiB
Python
230 lines
9.1 KiB
Python
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)
|