303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Task grant, ledger, and broker fast-forward contracts."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import time
|
|
from email.message import Message
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_scm_broker_support import _load, _object_entry, _pack_of, _receive_command
|
|
|
|
ROOT = Path(__file__).parents[2]
|
|
sys.path.insert(0, str(ROOT / "services/hermes/scm-common/scripts"))
|
|
import receive_pack_scan # noqa: E402
|
|
import scm_task_grants as grants # noqa: E402
|
|
|
|
|
|
KEY = b"a" * 64
|
|
|
|
|
|
def claims(**overrides):
|
|
value = {
|
|
"repo": "atlas-iac", "ref": "wt/t_root", "base": "main", "board": "titan-iac",
|
|
"root_task_id": "t_root", "assignment_task_id": "t_child", "run": "run_1",
|
|
"ordinal": 1, "expires": 101, "expected_old": "0" * 40, "new_head": "1" * 40,
|
|
"continuation_kind": "",
|
|
}
|
|
value.update(overrides)
|
|
return value
|
|
|
|
|
|
def test_grant_is_exact_signed_schema_and_expiry_bound():
|
|
grants = _load("scm_task_grants")
|
|
token = grants.sign_grant(claims(), KEY)
|
|
assert grants.verify_grant(token, key=KEY, now=100)["root_task_id"] == "t_root"
|
|
with pytest.raises(grants.PolicyError, match="signature"):
|
|
grants.verify_grant(token[:-1] + "A", key=KEY, now=100)
|
|
with pytest.raises(grants.PolicyError, match="expired"):
|
|
grants.verify_grant(token, key=KEY, now=101)
|
|
|
|
|
|
def test_ledger_requires_absent_initial_ref_and_exact_owner_cas(tmp_path):
|
|
grants = _load("scm_task_grants")
|
|
ledger = grants.TaskLedger(tmp_path / "ledger.db")
|
|
first = claims()
|
|
ledger.register(first, remote_head=None)
|
|
ledger.authorize_update(first)
|
|
ledger.commit(first)
|
|
assert ledger.get("atlas-iac", "wt/t_root") == ("titan-iac", "t_root", "1" * 40)
|
|
with pytest.raises(grants.PolicyError, match="head changed"):
|
|
ledger.authorize_update(first)
|
|
with pytest.raises(grants.PolicyError, match="another task"):
|
|
ledger.register(claims(root_task_id="t_other"), remote_head="1" * 40)
|
|
with pytest.raises(grants.PolicyError, match="requires operator adoption"):
|
|
ledger.register(claims(root_task_id="t_untracked", ref="wt/untracked"), remote_head="2" * 40)
|
|
with pytest.raises(grants.PolicyError, match="different branch"):
|
|
ledger.register(claims(ref="wt/fork"), remote_head=None)
|
|
|
|
|
|
def test_operator_adoption_seeds_only_the_exact_live_reviewed_branch(tmp_path):
|
|
grants = _load("scm_task_grants")
|
|
ledger = grants.TaskLedger(tmp_path / "ledger.db")
|
|
record = {
|
|
"repo": "atlas-iac", "ref": "wt/t_root", "board": "titan-iac",
|
|
"root_task_id": "t_root", "latest_head": "a" * 40, "pr_number": 55,
|
|
}
|
|
ledger.seed_adoption(record, "a" * 40)
|
|
assert ledger.get("atlas-iac", "wt/t_root") == ("titan-iac", "t_root", "a" * 40)
|
|
with pytest.raises(grants.PolicyError, match="does not match"):
|
|
ledger.seed_adoption({**record, "ref": "wt/other"}, "b" * 40)
|
|
|
|
# A later broker-confirmed revision survives a restart with the original
|
|
# reviewed import record still mounted.
|
|
ledger.commit(claims(expected_old="a" * 40, new_head="b" * 40))
|
|
ledger.seed_adoption(record, "b" * 40)
|
|
assert ledger.get("atlas-iac", "wt/t_root")[2] == "b" * 40
|
|
|
|
|
|
def test_fast_forward_requires_quarantined_parent_path():
|
|
old, middle, new = "a" * 40, "b" * 40, "c" * 40
|
|
assert receive_pack_scan._proves_descends(new, old, {new: (middle,), middle: (old,)})
|
|
assert not receive_pack_scan._proves_descends(new, old, {new: (middle,)})
|
|
assert not receive_pack_scan._proves_descends(new, old, {new: ("d" * 40,)})
|
|
|
|
|
|
def test_signed_default_wt_branch_accepts_a_real_commit_pack_but_unsigned_does_not():
|
|
raw = b"tree " + b"0" * 40 + b"\nauthor test <test@example> 1 +0000\ncommitter test <test@example> 1 +0000\n\nmessage\n"
|
|
head = hashlib.sha1(b"commit " + str(len(raw)).encode() + b"\0" + raw).hexdigest()
|
|
body = _receive_command(b"0" * 40, head.encode(), b"refs/heads/wt/t_default", _pack_of([_object_entry(1, raw)]))
|
|
assert receive_pack_scan.validate_receive_pack(body, "token", (b"token",), expected=("0" * 40, head, "wt/t_default")) == ("0" * 40, head, "wt/t_default")
|
|
with pytest.raises(receive_pack_scan.PolicyError, match="namespaced"):
|
|
receive_pack_scan.validate_receive_pack(body, "token", (b"token",))
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["main", "master", "refs/tags/v1"])
|
|
def test_granted_push_never_allows_base_or_tag_refs(ref):
|
|
if ref.startswith("refs/"):
|
|
raw_ref = ref.encode()
|
|
else:
|
|
raw_ref = f"refs/heads/{ref}".encode()
|
|
body = _receive_command(b"0" * 40, b"1" * 40, raw_ref)
|
|
with pytest.raises(receive_pack_scan.PolicyError):
|
|
receive_pack_scan.validate_receive_pack(body, "token", (b"token",), expected=("0" * 40, "1" * 40, ref))
|
|
|
|
|
|
def test_alias_requests_canonicalize_before_the_broker_forwards_them():
|
|
api = _load("gitea_api")
|
|
assert api.api_url(api.CANONICAL_BASE_URL, "/api/v1/repos/titan/titan-iac/pulls") == "https://scm.bstein.dev/api/v1/repos/titan/atlas-iac/pulls"
|
|
assert api.authorize_request("GET", "/api/v1/repos/titan/atlas-iac/branches/wt/t_root", None) == "branch"
|
|
|
|
|
|
def test_registration_uses_the_fixed_broker_http_envelope():
|
|
client = _load("scm_broker_client")
|
|
seen = []
|
|
|
|
class Response:
|
|
status = 200
|
|
|
|
class headers:
|
|
@staticmethod
|
|
def get_content_type():
|
|
return "application/json"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
@staticmethod
|
|
def read(_limit):
|
|
return b'{"registered":true}'
|
|
|
|
def opener(request, timeout):
|
|
seen.append((request, timeout))
|
|
return Response()
|
|
|
|
assert client.register_task("header-safe-grant", opener=opener) == b'{"registered":true}'
|
|
request, timeout = seen[0]
|
|
assert request.full_url == client.BROKER_ORIGIN + "/v1/tasks/register"
|
|
assert request.data == b'{"grant":"header-safe-grant"}' and timeout == 30
|
|
|
|
|
|
def test_draft_refresh_requires_exact_owned_pr_and_has_no_mutating_fields():
|
|
drafts = _load("scm_task_drafts")
|
|
grant, number, title, body = drafts.request_fields(
|
|
{"grant": "signed", "pr_number": 55, "title": "refresh", "body": "evidence"}, "token"
|
|
)
|
|
assert (grant, number, title, body) == ("signed", 55, "WIP: refresh", "evidence")
|
|
claims = {"repo": "atlas-iac", "ref": "wt/t_root", "base": "main", "new_head": "a" * 40}
|
|
pull = {"number": 55, "state": "open", "head": {"ref": "wt/t_root", "sha": "a" * 40, "repo": {"full_name": "titan/atlas-iac"}}, "base": {"ref": "main", "repo": {"full_name": "titan/atlas-iac"}}}
|
|
drafts.matches_pull(pull, claims, 55)
|
|
with pytest.raises(drafts.PolicyError, match="does not match"):
|
|
drafts.matches_pull({**pull, "base": {"ref": "master"}}, claims, 55)
|
|
with pytest.raises(drafts.PolicyError, match="fields"):
|
|
drafts.request_fields({"grant": "x", "pr_number": 55, "title": "x", "body": "x", "state": "closed"}, "token")
|
|
|
|
|
|
def _draft_update_response(status: int, payload: bytes, content_type: str = "application/json"):
|
|
class Response:
|
|
def __init__(self):
|
|
self.headers = Message()
|
|
|
|
def __enter__(self):
|
|
self.status = status
|
|
self.headers["Content-Type"] = content_type
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
@staticmethod
|
|
def read(_limit):
|
|
return payload
|
|
|
|
return Response()
|
|
|
|
|
|
@pytest.mark.parametrize("status", [200, 201])
|
|
def test_draft_update_accepts_only_known_success_statuses_with_matching_pr_shape(
|
|
monkeypatch, status
|
|
):
|
|
drafts = _load("scm_task_drafts")
|
|
body = json.dumps({
|
|
"number": 55,
|
|
"html_url": "https://scm.bstein.dev/titan/atlas-iac/pulls/55",
|
|
}).encode()
|
|
seen = []
|
|
|
|
def open_bounded(request, maximum, timeout):
|
|
seen.append((request, maximum, timeout))
|
|
return _draft_update_response(status, body)
|
|
|
|
monkeypatch.setattr(drafts.deadline_http, "open_bounded", open_bounded)
|
|
|
|
assert drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence") == body
|
|
request, maximum, timeout = seen[0]
|
|
assert request.get_method() == "PATCH"
|
|
assert request.full_url.endswith("/api/v1/repos/titan/atlas-iac/pulls/55")
|
|
assert json.loads(request.data) == {"title": "WIP: refreshed", "body": "evidence"}
|
|
assert maximum == 2 * 1024 * 1024 and timeout == 30
|
|
|
|
|
|
def test_draft_update_rejects_unapproved_success_status(monkeypatch):
|
|
drafts = _load("scm_task_drafts")
|
|
body = b'{"number":55,"html_url":"https://scm.bstein.dev/titan/atlas-iac/pulls/55"}'
|
|
monkeypatch.setattr(
|
|
drafts.deadline_http,
|
|
"open_bounded",
|
|
lambda *_args, **_kwargs: _draft_update_response(202, body),
|
|
)
|
|
|
|
with pytest.raises(drafts.PolicyError, match="upstream response"):
|
|
drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
b"[]",
|
|
b'{"number":56,"html_url":"https://scm.bstein.dev/titan/atlas-iac/pulls/55"}',
|
|
b'{"number":55,"html_url":"https://evil.example/titan/atlas-iac/pulls/55"}',
|
|
b"{not-json",
|
|
],
|
|
)
|
|
def test_draft_update_rejects_malformed_success_response_shape(monkeypatch, payload):
|
|
drafts = _load("scm_task_drafts")
|
|
monkeypatch.setattr(
|
|
drafts.deadline_http,
|
|
"open_bounded",
|
|
lambda *_args, **_kwargs: _draft_update_response(201, payload),
|
|
)
|
|
|
|
with pytest.raises(drafts.PolicyError, match="upstream response"):
|
|
drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence")
|
|
|
|
|
|
def _control_handler(broker, body: dict):
|
|
"""Build the broker's real control handler without opening a TCP listener."""
|
|
encoded = json.dumps(body).encode()
|
|
handler = object.__new__(broker.BrokerHandler)
|
|
headers = Message()
|
|
headers["Content-Type"] = "application/json"
|
|
headers["Content-Length"] = str(len(encoded))
|
|
handler.path = "/v1/tasks/draft-update"
|
|
handler.headers = headers
|
|
handler.rfile = io.BytesIO(encoded)
|
|
handler.wfile = io.BytesIO()
|
|
handler.connection = type("Connection", (), {"settimeout": staticmethod(lambda _value: None)})()
|
|
handler._json = lambda _status, value: handler.wfile.write(value)
|
|
return handler
|
|
|
|
|
|
def test_post_push_repair_refreshes_the_same_owned_pr_after_real_ledger_commit(tmp_path, monkeypatch):
|
|
"""Draft prose refresh follows an already committed old->new branch update."""
|
|
broker = _load("scm_broker")
|
|
ledger = grants.TaskLedger(tmp_path / "ledger.db")
|
|
old, new = "a" * 40, "b" * 40
|
|
initial = {
|
|
**claims(expected_old="0" * 40, new_head=old, expires=int(time.time()) + 120),
|
|
"continuation_kind": "implementation",
|
|
}
|
|
ledger.register(initial, remote_head=None)
|
|
ledger.commit(initial)
|
|
repair = {
|
|
**claims(
|
|
assignment_task_id="t_repair", expected_old=old, new_head=new,
|
|
expires=int(time.time()) + 120,
|
|
),
|
|
"continuation_kind": "repair",
|
|
}
|
|
# The receive-pack path has already performed the authenticated CAS. The
|
|
# following refresh must authorize the owner at `new`, not re-check old.
|
|
ledger.commit(repair)
|
|
token = grants.sign_grant(repair, KEY)
|
|
pull = {
|
|
"number": 55, "state": "open",
|
|
"head": {"ref": "wt/t_root", "sha": new, "repo": {"full_name": "titan/atlas-iac"}},
|
|
"base": {"ref": "main", "repo": {"full_name": "titan/atlas-iac"}},
|
|
}
|
|
updates = []
|
|
monkeypatch.setattr(broker, "read_token", lambda: "token")
|
|
monkeypatch.setattr(broker, "verify_grant", lambda value: grants.verify_grant(value, key=KEY))
|
|
monkeypatch.setattr(broker, "_task_ledger", lambda: ledger)
|
|
monkeypatch.setattr(broker, "_branch_head", lambda *_args: new)
|
|
monkeypatch.setattr(broker, "read", lambda *_args, **_kwargs: json.dumps(pull).encode())
|
|
monkeypatch.setattr(
|
|
broker, "update_draft",
|
|
lambda _token, repo, number, title, body: updates.append((repo, number, title, body))
|
|
or json.dumps({"number": number, "html_url": "https://scm.bstein.dev/titan/atlas-iac/pulls/55"}).encode(),
|
|
)
|
|
|
|
_control_handler(broker, {
|
|
"grant": token, "pr_number": 55, "title": "Repair CI failure", "body": "Tests: pytest",
|
|
})._control()
|
|
|
|
assert ledger.get("atlas-iac", "wt/t_root") == ("titan-iac", "t_root", new)
|
|
assert updates == [("atlas-iac", 55, "WIP: Repair CI failure", "Tests: pytest")]
|