atlas-iac/testing/tests/test_hermes_gitea_pr_client.py
2026-08-17 07:22:26 -03:00

323 lines
9.7 KiB
Python

"""Adversarial contracts for the Atlas-only draft pull-request client."""
from __future__ import annotations
import importlib.util
import io
import json
import sys
import urllib.error
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parents[2]
CLIENT_PATH = ROOT / "services/hermes/scripts/gitea_api.py"
def _load():
spec = importlib.util.spec_from_file_location("safe_gitea_api", CLIENT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _draft_payload(**updates):
payload = {
"base": "main",
"body": "Review evidence",
"head": "hermes/review-fix",
"title": "WIP: Repair review findings",
}
payload.update(updates)
return payload
class Response:
def __init__(self, body: object):
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return self.body
@pytest.mark.parametrize(
("base_url", "path"),
[
("https://evil.example", "/api/v1/repos/atlas/cassandra"),
("http://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev:443", "/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev", "https://evil.example/api/v1/repos/atlas/cassandra"),
("https://scm.bstein.dev", "/api/v1/repos/evil/cassandra"),
("https://scm.bstein.dev", "/api/v1/repos/%61tlas/cassandra"),
("https://scm.bstein.dev", "/api/v1/repos/atlas/../admin"),
],
)
def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: str):
client = _load()
with pytest.raises(client.PolicyError):
client.build_request("GET", path, base_url=base_url, token="secret")
@pytest.mark.parametrize(
("method", "path", "data"),
[
("DELETE", "/api/v1/repos/atlas/cassandra/pulls/4", None),
("POST", "/api/v1/repos/atlas/cassandra/pulls/4/merge", {}),
(
"POST",
"/api/v1/repos/atlas/cassandra/pulls/4/reviews",
{"event": "APPROVED"},
),
("PATCH", "/api/v1/repos/atlas/cassandra/pulls/4", {"state": "closed"}),
("PATCH", "/api/v1/repos/atlas/cassandra/pulls/4", {"draft": False}),
("PUT", "/api/v1/repos/atlas/cassandra/branches/main", {}),
],
)
def test_merge_approve_close_delete_and_other_mutations_are_rejected(
method: str, path: str, data: object
):
client = _load()
with pytest.raises(client.PolicyError):
client.build_request(
method, path, base_url=client.CANONICAL_BASE_URL, token="secret", data=data
)
def test_create_forces_draft_title_and_same_repository_branch_names():
client = _load()
assert (
client.authorize_request(
"POST", "/api/v1/repos/atlas/cassandra/pulls", _draft_payload()
)
== "create-draft"
)
with pytest.raises(client.PolicyError, match="draft-title prefix"):
client.authorize_request(
"POST",
"/api/v1/repos/atlas/cassandra/pulls",
_draft_payload(title="Not a draft"),
)
with pytest.raises(client.PolicyError):
client.authorize_request(
"POST",
"/api/v1/repos/atlas/cassandra/pulls",
_draft_payload(head="someone:branch"),
)
def test_update_checks_server_draft_state_before_patching():
client = _load()
calls = []
responses = iter(
[
Response({"number": 7, "draft": True}),
Response({"number": 7, "draft": True}),
]
)
def opener(request, timeout):
calls.append((request.method, request.full_url, request.data, timeout))
return next(responses)
result = client.update_draft(
"cassandra", 7, {"title": "Narrowed repair"}, token="runtime", opener=opener
)
assert json.loads(result) == {"number": 7, "draft": True}
assert [call[0] for call in calls] == ["GET", "PATCH"]
assert json.loads(calls[1][2]) == {"title": "WIP: Narrowed repair"}
def test_update_rejects_non_draft_without_sending_patch():
client = _load()
calls = []
def opener(request, timeout):
calls.append((request.method, timeout))
return Response({"number": 7, "draft": False})
with pytest.raises(client.PolicyError, match="human review now owns"):
client.update_draft(
"cassandra", 7, {"body": "new"}, token="runtime", opener=opener
)
assert calls == [("GET", 30)]
def test_runtime_token_is_only_an_authorization_header():
client = _load()
request = client.build_request(
"POST",
"/api/v1/repos/atlas/cassandra/pulls",
base_url=client.CANONICAL_BASE_URL,
token="do-not-leak",
data=_draft_payload(),
)
assert "do-not-leak" not in request.full_url
assert b"do-not-leak" not in request.data
assert request.get_header("Authorization") == "token do-not-leak"
def test_create_uses_live_gitea_schema_and_verifies_draft_response():
client = _load()
calls = []
def opener(request, timeout):
calls.append((json.loads(request.data), timeout))
return Response({"number": 3, "draft": True})
result = client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
title="Focused fix",
body="Evidence",
token="runtime",
opener=opener,
)
assert json.loads(result)["draft"] is True
assert calls[0][0]["title"] == "WIP: Focused fix"
assert "draft" not in calls[0][0]
def test_create_fails_closed_when_server_does_not_confirm_draft():
client = _load()
with pytest.raises(client.PolicyError, match="did not confirm draft"):
client.create_draft(
"cassandra",
base="main",
head="hermes/fix",
title="Focused fix",
body="Evidence",
token="runtime",
opener=lambda *_a, **_k: Response({"number": 3, "draft": False}),
)
def test_output_redaction_covers_exact_token_and_authorization_header():
client = _load()
raw = b'{"message":"do-not-leak","debug":"Authorization: token do-not-leak"}'
redacted = client.redact_bytes(raw, "do-not-leak")
assert b"do-not-leak" not in redacted
assert redacted.count(b"[REDACTED]") >= 1
def test_dry_run_does_not_read_token_or_use_network(
tmp_path: Path, monkeypatch, capsys
):
client = _load()
body = tmp_path / "body.md"
body.write_text("Evidence only\n", encoding="utf-8")
monkeypatch.setattr(client, "read_token", lambda: pytest.fail("read token"))
monkeypatch.setattr(
client.urllib.request, "urlopen", lambda *_a, **_k: pytest.fail("network")
)
assert (
client.main(
[
"--dry-run",
"create-draft",
"cassandra",
"--base",
"main",
"--head",
"hermes/fix",
"--title",
"Repair",
"--body-file",
str(body),
]
)
== 0
)
output = json.loads(capsys.readouterr().out)
assert output["operation"] == "create-draft"
assert output["owner"] == "atlas"
assert "Evidence only" not in json.dumps(output)
def test_http_error_path_redacts_token(monkeypatch, capsys):
client = _load()
monkeypatch.setattr(client, "read_token", lambda: "do-not-leak")
def fail(*_args, **_kwargs):
raise urllib.error.HTTPError(
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
403,
"forbidden",
{},
io.BytesIO(b"Authorization: token do-not-leak"),
)
monkeypatch.setattr(client, "read", lambda *_a, **_k: fail())
assert client.main(["read", "/api/v1/repos/atlas/cassandra"]) == 1
captured = capsys.readouterr()
assert "do-not-leak" not in captured.err
assert "HTTP 403" in captured.err
def test_flux_manifest_projects_runtime_vault_token_and_skill_only():
client_source = CLIENT_PATH.read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in client_source
assert "GITEA_TOKEN" not in client_source
deployment = yaml.safe_load(
(ROOT / "services/hermes/agent-deployment.yaml").read_text(encoding="utf-8")
)
template = deployment["spec"]["template"]
annotations = template["metadata"]["annotations"]
assert annotations["vault.hashicorp.com/agent-inject-secret-gitea-token"] == (
"kv/data/atlas/hermes/developer-gitea"
)
runtime = next(
volume
for volume in template["spec"]["volumes"]
if volume["name"] == "runtime-access"
)
assert runtime["emptyDir"]["medium"] == "Memory"
expected = {"hermes", "terminal", "cli-lane-runner"}
mounted = {
container["name"]
for container in template["spec"]["containers"]
if any(
mount["name"] == "atlas-pr-skill"
and mount["mountPath"]
== "/opt/data/workspace/skills/manage-atlas-pull-requests"
and mount.get("readOnly") is True
for mount in container.get("volumeMounts", [])
)
}
assert mounted == expected
kustomization = yaml.safe_load(
(ROOT / "services/hermes/kustomization.yaml").read_text(encoding="utf-8")
)
generator = next(
item
for item in kustomization["configMapGenerator"]
if item["name"] == "hermes-atlas-pr-skill"
)
assert generator["files"] == [
"SKILL.md=skills/manage-atlas-pull-requests/SKILL.md",
"openai.yaml=skills/manage-atlas-pull-requests/agents/openai.yaml",
]