test_hosted_dashboard_security.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
| 1 | """Security tests for hosted governance dashboard (§HGD.12).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from tests.fixtures.hosted_dashboard import start_test_hosted |
| 8 | from tools.hosted_dashboard.adapters.musehub import musehub_baseline_impossible |
| 9 | from tools.hosted_dashboard.http_client import ALLOWED_METHODS, UpstreamClient, UpstreamError |
| 10 | from tools.hosted_dashboard.scopes import refuse_write_scopes |
| 11 | from tools.hosted_dashboard.server import STATIC_ROOT |
| 12 | |
| 13 | |
| 14 | def test_mutating_methods_on_api() -> None: |
| 15 | handle, client, _ = start_test_hosted() |
| 16 | try: |
| 17 | for method in ("POST", "PUT", "PATCH", "DELETE"): |
| 18 | status, payload = client.request(method, "/api/org/summary") |
| 19 | assert status == 405 |
| 20 | assert payload["error"] == "method_not_allowed" |
| 21 | finally: |
| 22 | handle.shutdown() |
| 23 | |
| 24 | |
| 25 | def test_upstream_client_methods_get_head_only() -> None: |
| 26 | assert ALLOWED_METHODS == frozenset({"GET", "HEAD"}) |
| 27 | client = UpstreamClient(token=None) |
| 28 | |
| 29 | def boom(method, url, headers, timeout): |
| 30 | raise AssertionError("should not transport") |
| 31 | |
| 32 | client._transport = boom # type: ignore[method-assign] |
| 33 | with pytest.raises(UpstreamError) as exc: |
| 34 | client.request("POST", "https://api.github.com/user") |
| 35 | assert exc.value.token == "method_refused" |
| 36 | |
| 37 | |
| 38 | def test_rejected_scopes_refuse() -> None: |
| 39 | assert refuse_write_scopes(["administration"]) == "write_scope_refused" |
| 40 | assert refuse_write_scopes(["workflows"]) == "write_scope_refused" |
| 41 | |
| 42 | |
| 43 | def test_ssrf_disallowed_host_and_ip() -> None: |
| 44 | client = UpstreamClient(token=None) |
| 45 | for url in ( |
| 46 | "https://169.254.169.254/latest/meta-data", |
| 47 | "https://127.0.0.1/repos", |
| 48 | "https://evil.example/x", |
| 49 | "http://[::1]/", |
| 50 | ): |
| 51 | with pytest.raises(UpstreamError) as exc: |
| 52 | client.get_json(url) |
| 53 | assert exc.value.token == "upstream_host_refused" |
| 54 | |
| 55 | |
| 56 | def test_path_traversal_in_params_refused() -> None: |
| 57 | handle, client, _ = start_test_hosted() |
| 58 | try: |
| 59 | status, payload = client.get("/api/repos/../etc/passwd/roadmap") |
| 60 | assert status in {400, 404} |
| 61 | finally: |
| 62 | handle.shutdown() |
| 63 | |
| 64 | |
| 65 | def test_missing_auth_401_except_health() -> None: |
| 66 | handle, client, _ = start_test_hosted() |
| 67 | try: |
| 68 | status, payload = client.get("/api/org/summary", auth=False) |
| 69 | assert status == 401 |
| 70 | assert payload["error"] == "auth" |
| 71 | status, health = client.get("/api/health", auth=False) |
| 72 | assert status == 200 |
| 73 | finally: |
| 74 | handle.shutdown() |
| 75 | |
| 76 | |
| 77 | def test_no_localstorage_viewer_token() -> None: |
| 78 | js = (STATIC_ROOT / "assets" / "dashboard.js").read_text(encoding="utf-8") |
| 79 | assert "localStorage" not in js |
| 80 | assert "sessionStorage" not in js |
| 81 | assert "document.cookie" not in js |
| 82 | |
| 83 | |
| 84 | def test_no_auth_disable_flag_in_cli_help() -> None: |
| 85 | from cli.main import build_parser |
| 86 | |
| 87 | help_text = build_parser().format_help() |
| 88 | assert "--disable-auth" not in help_text |
| 89 | assert "hosted-dashboard" in help_text |
| 90 | |
| 91 | |
| 92 | def test_musehub_only_baseline_impossible() -> None: |
| 93 | assert musehub_baseline_impossible(github_contents_enabled=False) is True |
| 94 | assert musehub_baseline_impossible(github_contents_enabled=True) is False |
| 95 | |
| 96 | |
| 97 | def test_no_deploy_probe_urls_in_module() -> None: |
| 98 | from pathlib import Path |
| 99 | |
| 100 | root = Path(__file__).resolve().parents[2] / "tools" / "hosted_dashboard" |
| 101 | text = "" |
| 102 | for path in root.rglob("*.py"): |
| 103 | text += path.read_text(encoding="utf-8") |
| 104 | assert "production_health" not in text |
| 105 | assert "http://prod" not in text |
| 106 | assert "https://prod" not in text |
| 107 | |
| 108 | |
| 109 | def test_deploy_route_not_registered() -> None: |
| 110 | handle, client, _ = start_test_hosted() |
| 111 | try: |
| 112 | status, payload = client.get("/api/deploy") |
| 113 | assert status == 404 |
| 114 | finally: |
| 115 | handle.shutdown() |
File History
1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago