__init__.py python
301 lines 10.5 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago
1 """Fixture helpers for hosted governance dashboard tests (§HGD.12)."""
2
3 from __future__ import annotations
4
5 import base64
6 import json
7 import socket
8 import urllib.error
9 import urllib.request
10 from pathlib import Path
11 from typing import Any, Callable
12 from urllib.parse import parse_qs, urlparse
13
14 from tools.hosted_dashboard.adapters.github import GitHubAdapters
15 from tools.hosted_dashboard.auth import generate_viewer_token
16 from tools.hosted_dashboard.config import HostedDashboardConfig, parse_hosted_dashboard_config
17 from tools.hosted_dashboard.handlers import DashboardService
18 from tools.hosted_dashboard.http_client import UpstreamClient, UpstreamResponse
19 from tools.hosted_dashboard.server import HostedServerConfig, HostedServerHandle, start_hosted_server
20
21 MARKER_YAML = """\
22 overseer_config_version: 1
23 repo:
24 name: demo
25 root_relative_docs: docs
26 vcs:
27 regime: git-only
28 canonical: git
29 git:
30 remote: origin
31 main_branch: main
32 mirror_branch: null
33 feature_branch_pattern: "feat/*"
34 muse:
35 staging_remote: null
36 main_branch: null
37 docs:
38 handover: OVERSEER-HANDOVER.md
39 roadmap: ROADMAP.md
40 coordination: null
41 standing_decisions: STANDING-DECISIONS.md
42 handover_title: Handover
43 roadmap_title: Roadmap
44 thresholds:
45 realign_max_commits: 5
46 drift_warn_only: true
47 freeze_contract:
48 enabled: true
49 reviewer:
50 mode: agent
51 model: thinking-high
52 provider: local
53 fallback: human
54 human_escalation: [security, irreversible, real_money, gates_tier3]
55 """
56
57 ROADMAP_MD = """\
58 # Roadmap fixture
59
60 | Phase | Model | Status | Notes |
61 | --- | --- | --- | --- |
62 | **Alpha** | Auto | **DONE** | shipped |
63 | **Beta** | Thinking | **WIP** | in progress |
64 | **Gamma** | Auto | **TODO** | pending |
65 """
66
67 HANDOVER_MD = """\
68 # Handover fixture
69
70 ## Pending gates
71
72 - freeze-review for Gamma still open
73 - build-verification not yet run
74
75 ## Snapshot
76 ok
77 """
78
79
80 def free_port() -> int:
81 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
82 sock.bind(("127.0.0.1", 0))
83 return int(sock.getsockname()[1])
84
85
86 class FixtureUpstream:
87 """In-memory GitHub Contents-shaped fixture (no real network)."""
88
89 def __init__(self) -> None:
90 self.files: dict[tuple[str, str, str, str], bytes] = {}
91 self.repos: dict[tuple[str, str], dict[str, Any]] = {}
92 self.org_repos: dict[str, list[dict[str, Any]]] = {}
93 self.calls: list[tuple[str, str]] = []
94 self.force_status: dict[str, int] = {}
95
96 def put_repo(self, owner: str, name: str, *, default_branch: str = "main", private: bool = False) -> None:
97 self.repos[(owner, name)] = {
98 "name": name,
99 "full_name": f"{owner}/{name}",
100 "default_branch": default_branch,
101 "private": private,
102 "owner": {"login": owner},
103 }
104 self.org_repos.setdefault(owner, [])
105 entry = self.repos[(owner, name)]
106 if entry not in self.org_repos[owner]:
107 self.org_repos[owner].append(entry)
108
109 def put_file(self, owner: str, repo: str, path: str, content: str | bytes, *, ref: str = "main") -> None:
110 raw = content.encode("utf-8") if isinstance(content, str) else content
111 self.files[(owner, repo, ref, path)] = raw
112
113 def seed_kit_repo(self, owner: str = "acme", repo: str = "kit-demo") -> None:
114 self.put_repo(owner, repo)
115 self.put_file(owner, repo, ".overseer/config.yaml", MARKER_YAML)
116 self.put_file(owner, repo, "docs/ROADMAP.md", ROADMAP_MD)
117 self.put_file(owner, repo, "docs/OVERSEER-HANDOVER.md", HANDOVER_MD)
118
119 def transport(self, method: str, url: str, headers: dict[str, str], timeout: float) -> UpstreamResponse:
120 self.calls.append((method, url))
121 if method not in {"GET", "HEAD"}:
122 raise AssertionError(f"write verb attempted: {method}")
123 parsed = urlparse(url)
124 host = parsed.hostname or ""
125 if host not in {"api.github.com", "raw.githubusercontent.com"}:
126 # Host allowlist in client should have refused before transport; if not, 403 token path.
127 return UpstreamResponse(status=403, headers={}, body=b"host refused")
128
129 path = parsed.path
130 qs = parse_qs(parsed.query)
131
132 # Force status override by path prefix
133 for prefix, status in self.force_status.items():
134 if prefix in url:
135 return UpstreamResponse(status=status, headers={}, body=b"forced")
136
137 # /repos/{owner}/{repo}
138 parts = [p for p in path.split("/") if p]
139 if len(parts) == 3 and parts[0] == "repos":
140 owner, repo = parts[1], parts[2]
141 meta = self.repos.get((owner, repo))
142 if meta is None:
143 return UpstreamResponse(status=404, headers={}, body=b"{}")
144 body = json.dumps(meta).encode("utf-8")
145 return UpstreamResponse(status=200, headers={"content-type": "application/json"}, body=body)
146
147 # /repos/{o}/{r}/contents/{path}
148 if len(parts) >= 4 and parts[0] == "repos" and parts[3] == "contents":
149 owner, repo = parts[1], parts[2]
150 file_path = "/".join(parts[4:])
151 ref = (qs.get("ref") or ["main"])[0]
152 raw = self.files.get((owner, repo, ref, file_path))
153 if raw is None:
154 return UpstreamResponse(status=404, headers={}, body=b"{}")
155 accept = headers.get("Accept", "")
156 if "raw" in accept:
157 return UpstreamResponse(status=200, headers={}, body=raw if method == "GET" else b"")
158 payload = {
159 "encoding": "base64",
160 "content": base64.b64encode(raw).decode("ascii"),
161 "path": file_path,
162 }
163 return UpstreamResponse(
164 status=200,
165 headers={"content-type": "application/json"},
166 body=json.dumps(payload).encode("utf-8") if method == "GET" else b"",
167 )
168
169 # /orgs/{owner}/repos or /users/{owner}/repos
170 if len(parts) == 3 and parts[0] in {"orgs", "users"} and parts[2] == "repos":
171 owner = parts[1]
172 data = self.org_repos.get(owner, [])
173 return UpstreamResponse(
174 status=200,
175 headers={"content-type": "application/json"},
176 body=json.dumps(data).encode("utf-8"),
177 )
178
179 # check-runs
180 if len(parts) >= 5 and parts[3] == "commits" and parts[-1] == "check-runs":
181 payload = {
182 "check_runs": [
183 {"name": "ci", "conclusion": "success"},
184 ]
185 }
186 return UpstreamResponse(
187 status=200,
188 headers={"content-type": "application/json"},
189 body=json.dumps(payload).encode("utf-8"),
190 )
191
192 return UpstreamResponse(status=404, headers={}, body=b"{}")
193
194
195 class HostedHttpClient:
196 """Minimal HTTP client for hosted dashboard tests."""
197
198 def __init__(self, base_url: str, viewer_token: str) -> None:
199 self.base_url = base_url.rstrip("/")
200 self.viewer_token = viewer_token
201
202 def request(
203 self,
204 method: str,
205 path: str,
206 *,
207 headers: dict[str, str] | None = None,
208 origin: str | None = None,
209 auth: bool = True,
210 ) -> tuple[int, dict[str, Any] | str]:
211 url = f"{self.base_url}{path}"
212 req_headers = dict(headers or {})
213 if auth:
214 req_headers.setdefault("Authorization", f"Bearer {self.viewer_token}")
215 if origin is not None:
216 req_headers["Origin"] = origin
217 request = urllib.request.Request(url, method=method, headers=req_headers)
218 try:
219 with urllib.request.urlopen(request, timeout=10) as response:
220 raw = response.read().decode("utf-8")
221 try:
222 return response.status, json.loads(raw)
223 except json.JSONDecodeError:
224 return response.status, raw
225 except urllib.error.HTTPError as exc:
226 raw = exc.read().decode("utf-8")
227 try:
228 return exc.code, json.loads(raw)
229 except json.JSONDecodeError:
230 return exc.code, raw
231
232 def get(self, path: str, **kwargs: Any) -> tuple[int, dict[str, Any] | str]:
233 return self.request("GET", path, **kwargs)
234
235
236 def start_test_hosted(
237 *,
238 upstream: FixtureUpstream | None = None,
239 config: HostedDashboardConfig | None = None,
240 viewer_token: str | None = None,
241 port: int | None = None,
242 write_scope_refused: bool = False,
243 cors_origins: tuple[str, ...] | None = None,
244 ) -> tuple[HostedServerHandle, HostedHttpClient, FixtureUpstream]:
245 """Start hosted dashboard against fixture upstream."""
246 fixture = upstream or FixtureUpstream()
247 if not fixture.repos:
248 fixture.seed_kit_repo()
249
250 dash = config
251 if dash is None:
252 raw = {
253 "enabled": True,
254 "allow_non_loopback": False,
255 "cors_origins": list(cors_origins or ()),
256 "org_allowlist": ["acme/kit-demo"],
257 "sources": {
258 "github_contents": True,
259 "github_meta": True,
260 "github_checks_advisory": False,
261 "musehub_read": False,
262 },
263 }
264 dash = parse_hosted_dashboard_config(raw)
265
266 token = viewer_token or generate_viewer_token()
267 client = UpstreamClient(token="test-upstream", transport=fixture.transport)
268 adapters = GitHubAdapters(
269 client,
270 checks_advisory=dash.sources.github_checks_advisory,
271 enumeration_cap=dash.enumeration_cap,
272 max_doc_bytes=dash.max_doc_bytes,
273 )
274 service = DashboardService(adapters, dash)
275 chosen = port or free_port()
276 server_config = HostedServerConfig(
277 bind="127.0.0.1",
278 port=chosen,
279 viewer_token=token,
280 upstream_token="test-upstream",
281 dashboard=dash,
282 service=service,
283 require_loopback_peer=True,
284 write_scope_refused=write_scope_refused,
285 )
286 handle = start_hosted_server(server_config)
287 http = HostedHttpClient(handle.base_url, token)
288 return handle, http, fixture
289
290
291 def write_hosted_config(path: Path, block: dict[str, Any]) -> Path:
292 """Write a minimal overseer config with hosted_dashboard block for CLI tests."""
293 from tests.support import FIXTURES
294 import yaml
295 import shutil
296
297 base = yaml.safe_load((FIXTURES / "config-git-only.yaml").read_text(encoding="utf-8"))
298 base["hosted_dashboard"] = block
299 path.parent.mkdir(parents=True, exist_ok=True)
300 path.write_text(yaml.safe_dump(base, sort_keys=False), encoding="utf-8")
301 return path
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago