323 lines
9.7 KiB
Python
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",
|
|
]
|