CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 14s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / lint (push) Failing after 34s
CI Pipeline / canary (push) Successful in 37s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / unit-tests (push) Successful in 1m4s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 48s
CORRECTION 1: #314 reopened — recall_with_associations mode field defect CONFIRMED in current source and FIXED (mode: association_merged). CORRECTION 3: Gitea credential removed from remote URL (credential helper). TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires Gitea web UI access). Remote URL now shows username only, no secret. CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence computed warranted bump from commits-since-tag without checking if master version already satisfies the bump. FIX: already-converged guard in version_convergence.py (master version > last tag → no commit). Repo-aware (args.repo passed to read_version/latest_tag). CORRECTION 6: lifecycle terminalization — execution exception now guarantees task terminalizes as failed with evidence (no immortal RUNNING). CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction (NOT shared operator state). Read-only leading-verb override. Isolation tests prove no cross-contamination between interleaved tasks. PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics, route-instruction learning loop (PE-1789102658-0269 SEED). 1253 tests green; ratchet 352/80 (shrink); henxels clean.
808 lines
32 KiB
Python
808 lines
32 KiB
Python
import base64
|
|
import contextlib
|
|
import http.client
|
|
import http.server
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
import urllib.parse
|
|
import urllib.request
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from shared.gitea_provision import (
|
|
ManifestError,
|
|
ProvisioningError,
|
|
Provisioner,
|
|
UrllibTransport,
|
|
_SameOriginRedirectHandler,
|
|
load_manifest,
|
|
main,
|
|
normalize_color,
|
|
normalize_instant,
|
|
run,
|
|
)
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
TRACKED_MANIFEST = REPOSITORY_ROOT / "config/gitea_repository_state.json"
|
|
|
|
|
|
class _RedirectServer:
|
|
def __init__(self, handler):
|
|
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
|
|
@property
|
|
def url(self):
|
|
host, port = self.server.server_address
|
|
return f"http://{host}:{port}"
|
|
|
|
def __enter__(self):
|
|
self.thread.start()
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
self.server.shutdown()
|
|
self.thread.join()
|
|
self.server.server_close()
|
|
|
|
|
|
class UrllibTransportTests(unittest.TestCase):
|
|
def test_cli_reports_malformed_base_url_without_traceback_or_input_leakage(self):
|
|
unsafe_url = "https://[::1/unsafe-base-url-marker"
|
|
stderr = io.StringIO()
|
|
argv = [
|
|
"gitea_provision.py",
|
|
"--manifest",
|
|
str(TRACKED_MANIFEST),
|
|
"--gitea-url",
|
|
unsafe_url,
|
|
"--check",
|
|
]
|
|
|
|
with (
|
|
mock.patch("sys.argv", argv),
|
|
mock.patch(
|
|
"shared.gitea_provision._default_token_loader",
|
|
return_value="test-token",
|
|
),
|
|
mock.patch("urllib.request.build_opener") as build_opener,
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
result = main()
|
|
|
|
output = stderr.getvalue()
|
|
self.assertEqual(result, 2)
|
|
self.assertEqual(output, "error: Gitea API base URL is invalid\n")
|
|
self.assertNotIn("Traceback", output)
|
|
self.assertNotIn(unsafe_url, output)
|
|
self.assertNotIn("unsafe-base-url-marker", output)
|
|
build_opener.assert_not_called()
|
|
|
|
def test_plaintext_non_loopback_base_url_is_rejected_before_request(self):
|
|
token = "plaintext-transport-test-secret"
|
|
|
|
with mock.patch("urllib.request.build_opener") as build_opener:
|
|
with self.assertRaises(ProvisioningError) as raised:
|
|
UrllibTransport("http://example.com", token).request("GET", "/api/v1/version")
|
|
|
|
self.assertIn("HTTPS", str(raised.exception))
|
|
self.assertNotIn(token, str(raised.exception))
|
|
build_opener.assert_not_called()
|
|
|
|
def test_plaintext_literal_loopback_base_urls_are_allowed(self):
|
|
for base_url in (
|
|
"http://localhost:3000",
|
|
"http://127.42.0.9:3000",
|
|
"http://[::1]:3000",
|
|
):
|
|
with self.subTest(base_url=base_url):
|
|
transport = UrllibTransport(base_url, "loopback-test-secret")
|
|
self.assertEqual(transport.base_url, base_url)
|
|
|
|
def test_http_error_omits_server_controlled_content_and_retains_request_context(
|
|
self,
|
|
):
|
|
token = "runtime-token-boundary-test-secret"
|
|
response_body = f"server diagnostic containing {token}"
|
|
|
|
class TokenError(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(500, f"server reason containing {token}")
|
|
self.end_headers()
|
|
self.wfile.write(response_body.encode())
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(TokenError) as server:
|
|
transport = UrllibTransport(server.url, token)
|
|
with self.assertRaises(ProvisioningError) as raised:
|
|
transport.request("GET", "/token-error")
|
|
|
|
message = str(raised.exception)
|
|
self.assertEqual(message, "Gitea API GET /token-error failed (500)")
|
|
self.assertNotIn("server reason", message)
|
|
self.assertNotIn("server diagnostic", message)
|
|
self.assertNotIn(token, message)
|
|
|
|
def test_cross_origin_redirect_is_rejected_without_forwarding_authorization(self):
|
|
received_authorization = []
|
|
|
|
response = mock.Mock()
|
|
request = urllib.request.Request("http://127.0.0.1/source")
|
|
headers = http.client.HTTPMessage()
|
|
headers["Location"] = "http://127.0.0.2/destination"
|
|
with self.assertRaisesRegex(ProvisioningError, "cross-origin redirect"):
|
|
_SameOriginRedirectHandler().http_error_302(
|
|
request,
|
|
response,
|
|
302,
|
|
"Found",
|
|
headers,
|
|
)
|
|
response.close.assert_called_once_with()
|
|
|
|
class Destination(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
received_authorization.append(self.headers.get("Authorization"))
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(Destination) as destination:
|
|
target = f"{destination.url}/captured"
|
|
|
|
class Redirect(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(302)
|
|
self.send_header("Location", target)
|
|
self.end_headers()
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(Redirect) as source:
|
|
transport = UrllibTransport(source.url, "transport-test-secret")
|
|
with self.assertRaisesRegex(ProvisioningError, "cross-origin redirect"):
|
|
transport.request("GET", "/redirect")
|
|
|
|
self.assertEqual(received_authorization, [])
|
|
|
|
def test_same_origin_redirect_preserves_authorization(self):
|
|
received_authorization = []
|
|
|
|
class Redirect(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path == "/redirect":
|
|
self.send_response(302)
|
|
self.send_header("Location", "/destination")
|
|
self.end_headers()
|
|
return
|
|
received_authorization.append(self.headers.get("Authorization"))
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(Redirect) as server:
|
|
result = UrllibTransport(server.url, "same-origin-test-secret").request("GET", "/redirect")
|
|
|
|
self.assertEqual(result, {})
|
|
self.assertEqual(received_authorization, ["token same-origin-test-secret"])
|
|
|
|
def test_malformed_redirect_location_raises_sanitized_provisioning_error(self):
|
|
token = "malformed-redirect-test-secret"
|
|
location = f"http://127.0.0.1:not-a-port/server-location-marker/{token}"
|
|
|
|
response = mock.Mock()
|
|
request = urllib.request.Request("http://127.0.0.1/redirect")
|
|
headers = http.client.HTTPMessage()
|
|
headers["Location"] = location
|
|
with self.assertRaisesRegex(ProvisioningError, "malformed redirect"):
|
|
_SameOriginRedirectHandler().http_error_302(
|
|
request,
|
|
response,
|
|
302,
|
|
"Found",
|
|
headers,
|
|
)
|
|
response.close.assert_called_once_with()
|
|
|
|
class MalformedRedirect(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(302)
|
|
self.send_header("Location", location)
|
|
self.end_headers()
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(MalformedRedirect) as server:
|
|
transport = UrllibTransport(server.url, token)
|
|
with self.assertRaises(ProvisioningError) as raised:
|
|
transport.request("GET", "/redirect")
|
|
|
|
message = str(raised.exception)
|
|
self.assertEqual(message, "Gitea API refused malformed redirect")
|
|
self.assertNotIn(location, message)
|
|
self.assertNotIn("server-location-marker", message)
|
|
self.assertNotIn(token, message)
|
|
|
|
def test_malformed_ipv6_redirect_location_raises_sanitized_provisioning_error(self):
|
|
token = "malformed-ipv6-redirect-test-secret"
|
|
location = "https://[::1/unsafe-redirect-marker"
|
|
|
|
class MalformedRedirect(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(302)
|
|
self.send_header("Location", location)
|
|
self.end_headers()
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(MalformedRedirect) as server:
|
|
transport = UrllibTransport(server.url, token)
|
|
with self.assertRaises(ProvisioningError) as raised:
|
|
transport.request("GET", "/redirect")
|
|
|
|
message = str(raised.exception)
|
|
self.assertEqual(message, "Gitea API refused malformed redirect")
|
|
self.assertNotIn(location, message)
|
|
self.assertNotIn("unsafe-redirect-marker", message)
|
|
self.assertNotIn(token, message)
|
|
|
|
def test_successful_response_with_invalid_json_raises_sanitized_error(self):
|
|
token = "invalid-json-test-secret"
|
|
|
|
class InvalidJson(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(f'{{"secret":"{token}"'.encode())
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(InvalidJson) as server:
|
|
transport = UrllibTransport(server.url, token)
|
|
with self.assertRaises(ProvisioningError) as raised:
|
|
transport.request("GET", "/invalid-json")
|
|
|
|
message = str(raised.exception)
|
|
self.assertIn("invalid JSON response", message)
|
|
self.assertNotIn(token, message)
|
|
self.assertNotIn("secret", message)
|
|
|
|
def test_cli_reports_invalid_json_without_traceback_or_token_leakage(self):
|
|
token = "cli-invalid-json-test-secret"
|
|
|
|
class InvalidJson(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(f'{{"secret":"{token}"'.encode())
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(InvalidJson) as server:
|
|
stderr = io.StringIO()
|
|
argv = [
|
|
"gitea_provision.py",
|
|
"--manifest",
|
|
str(TRACKED_MANIFEST),
|
|
"--gitea-url",
|
|
server.url,
|
|
"--check",
|
|
]
|
|
with (
|
|
mock.patch("sys.argv", argv),
|
|
mock.patch("shared.gitea_provision._default_token_loader", return_value=token),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
result = main()
|
|
|
|
output = stderr.getvalue()
|
|
self.assertEqual(result, 2)
|
|
self.assertIn("invalid JSON response", output)
|
|
self.assertNotIn("Traceback", output)
|
|
self.assertNotIn(token, output)
|
|
self.assertNotIn("secret", output)
|
|
|
|
def test_cli_reports_response_read_errors_without_traceback_or_body_content(self):
|
|
token = "cli-read-timeout-test-secret"
|
|
response_content = f"response body containing {token}"
|
|
argv = [
|
|
"gitea_provision.py",
|
|
"--manifest",
|
|
str(TRACKED_MANIFEST),
|
|
"--check",
|
|
]
|
|
|
|
for error_type in (TimeoutError, OSError):
|
|
with self.subTest(error_type=error_type.__name__):
|
|
opener = mock.MagicMock()
|
|
opener.open.return_value.__enter__.return_value.read.side_effect = error_type(response_content)
|
|
stderr = io.StringIO()
|
|
with (
|
|
mock.patch("sys.argv", argv),
|
|
mock.patch(
|
|
"shared.gitea_provision._default_token_loader",
|
|
return_value=token,
|
|
),
|
|
mock.patch("urllib.request.build_opener", return_value=opener),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
result = main()
|
|
|
|
output = stderr.getvalue()
|
|
self.assertEqual(result, 2)
|
|
self.assertIn("failed while reading response", output)
|
|
self.assertNotIn("Traceback", output)
|
|
self.assertNotIn(response_content, output)
|
|
self.assertNotIn(token, output)
|
|
|
|
def test_cli_reports_incomplete_response_read_without_exception_content(self):
|
|
token = "cli-incomplete-read-test-secret"
|
|
partial_response = f"partial response containing {token}".encode()
|
|
opener = mock.MagicMock()
|
|
opener.open.return_value.__enter__.return_value.read.side_effect = http.client.IncompleteRead(
|
|
partial_response,
|
|
len(partial_response) + 10,
|
|
)
|
|
stderr = io.StringIO()
|
|
argv = [
|
|
"gitea_provision.py",
|
|
"--manifest",
|
|
str(TRACKED_MANIFEST),
|
|
"--check",
|
|
]
|
|
|
|
with (
|
|
mock.patch("sys.argv", argv),
|
|
mock.patch("shared.gitea_provision._default_token_loader", return_value=token),
|
|
mock.patch("urllib.request.build_opener", return_value=opener),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
result = main()
|
|
|
|
output = stderr.getvalue()
|
|
self.assertEqual(result, 2)
|
|
self.assertIn("failed while reading response", output)
|
|
self.assertNotIn("IncompleteRead", output)
|
|
self.assertNotIn("Traceback", output)
|
|
self.assertNotIn("partial response", output)
|
|
self.assertNotIn(token, output)
|
|
|
|
def test_cli_http_error_omits_response_body_containing_only_runtime_token_prefix(
|
|
self,
|
|
):
|
|
token = "cli-http-prefix-test-secret"
|
|
token_prefix = token[:15]
|
|
|
|
class PrefixError(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(500)
|
|
self.end_headers()
|
|
self.wfile.write(token_prefix.encode())
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
with _RedirectServer(PrefixError) as server:
|
|
stderr = io.StringIO()
|
|
argv = [
|
|
"gitea_provision.py",
|
|
"--manifest",
|
|
str(TRACKED_MANIFEST),
|
|
"--gitea-url",
|
|
server.url,
|
|
"--check",
|
|
]
|
|
with (
|
|
mock.patch("sys.argv", argv),
|
|
mock.patch("shared.gitea_provision._default_token_loader", return_value=token),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
result = main()
|
|
|
|
output = stderr.getvalue()
|
|
self.assertEqual(result, 2)
|
|
self.assertIn("Gitea API GET /api/v1/version failed (500)", output)
|
|
self.assertNotIn("Internal Server Error", output)
|
|
self.assertNotIn(token_prefix, output)
|
|
|
|
|
|
class FixturePathTests(unittest.TestCase):
|
|
def test_config_and_wiki_fixture_test_passes_from_external_cwd(self):
|
|
with tempfile.TemporaryDirectory() as external_cwd:
|
|
environment = os.environ.copy()
|
|
environment["PYTHONPATH"] = str(REPOSITORY_ROOT)
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"unittest",
|
|
"tests.test_gitea_provision.ProvisionerTests.test_tracked_manifest_requires_all_managed_object_counts",
|
|
],
|
|
cwd=external_cwd,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
DESIRED = {
|
|
"repository": {"owner": "conductor", "name": "rune", "default_branch": "master"},
|
|
"branch_protection": {
|
|
"branch_name": "master",
|
|
"enable_push": True,
|
|
"enable_push_whitelist": True,
|
|
"push_whitelist_usernames": [],
|
|
"enable_force_push": False,
|
|
"enable_merge_whitelist": False,
|
|
"required_approvals": 0,
|
|
"enable_status_check": True,
|
|
"status_check_contexts": ["CI Pipeline / required-checks (pull_request)"],
|
|
"block_on_rejected_reviews": True,
|
|
"block_on_outdated_branch": True,
|
|
"dismiss_stale_approvals": True,
|
|
},
|
|
"labels": [
|
|
{"name": "priority/high", "color": "#D73A4A", "description": "High priority"},
|
|
{"name": "type/bug", "color": "0075CA", "description": "Something is broken"},
|
|
],
|
|
"milestones": [
|
|
{
|
|
"title": "Foundation",
|
|
"description": "Build the base",
|
|
"state": "open",
|
|
"due_on": "2026-08-31T00:00:00Z",
|
|
}
|
|
],
|
|
"wiki_pages": [
|
|
{"title": "Home", "slug": "Home", "source": "docs/wiki/Home.md"},
|
|
],
|
|
}
|
|
|
|
|
|
class FakeTransport:
|
|
WIKI_PAGES_PATH = "/api/v1/repos/conductor/rune/wiki/pages"
|
|
WIKI_NEW_PATH = "/api/v1/repos/conductor/rune/wiki/new"
|
|
|
|
def __init__(self, state=None, page_size=50):
|
|
state = state or {}
|
|
self.branch = deepcopy(state.get("branch"))
|
|
self.labels = deepcopy(state.get("labels", []))
|
|
self.milestones = deepcopy(state.get("milestones", []))
|
|
self.wiki = deepcopy(state.get("wiki", {}))
|
|
self.calls = []
|
|
self.page_size = page_size
|
|
self.next_id = 100
|
|
|
|
def request(self, method, path, *, query=None, payload=None):
|
|
self.calls.append((method, path, deepcopy(query), deepcopy(payload)))
|
|
if path.endswith("/branch_protections"):
|
|
if method == "GET":
|
|
return [] if self.branch is None else [deepcopy(self.branch)]
|
|
self.branch = deepcopy(payload)
|
|
return deepcopy(self.branch)
|
|
if "/branch_protections/" in path:
|
|
self.branch.update(deepcopy(payload))
|
|
return deepcopy(self.branch)
|
|
if path.endswith("/labels"):
|
|
if method == "GET":
|
|
return self._page(self.labels, query)
|
|
item = {"id": self._id(), **deepcopy(payload)}
|
|
self.labels.append(item)
|
|
return deepcopy(item)
|
|
if "/labels/" in path:
|
|
item = self._by_id(self.labels, path)
|
|
item.update(deepcopy(payload))
|
|
return deepcopy(item)
|
|
if path.endswith("/milestones"):
|
|
if method == "GET":
|
|
self.assert_state_all(query)
|
|
return self._page(self.milestones, query)
|
|
item = {"id": self._id(), **deepcopy(payload)}
|
|
self.milestones.append(item)
|
|
return deepcopy(item)
|
|
if "/milestones/" in path:
|
|
item = self._by_id(self.milestones, path)
|
|
item.update(deepcopy(payload))
|
|
return deepcopy(item)
|
|
if path == self.WIKI_PAGES_PATH and method == "GET":
|
|
pages = [{"title": value["title"], "sub_url": key} for key, value in self.wiki.items()]
|
|
return self._page(pages, query)
|
|
if path == self.WIKI_NEW_PATH and method == "POST":
|
|
decoded = base64.b64decode(payload["content_base64"]).decode("utf-8")
|
|
self.wiki[payload["title"]] = {
|
|
"title": payload["title"],
|
|
"content": decoded,
|
|
}
|
|
return deepcopy(self.wiki[payload["title"]])
|
|
if "/wiki/page/" in path:
|
|
slug = urllib.parse.unquote(path.rsplit("/", 1)[1])
|
|
if method == "GET":
|
|
return deepcopy(self.wiki[slug])
|
|
decoded = base64.b64decode(payload["content_base64"]).decode("utf-8")
|
|
self.wiki[slug] = {"title": payload["title"], "content": decoded}
|
|
return deepcopy(self.wiki[slug])
|
|
raise AssertionError(f"unexpected request: {method} {path}")
|
|
|
|
def _page(self, values, query):
|
|
page = int((query or {}).get("page", 1))
|
|
limit = int((query or {}).get("limit", self.page_size))
|
|
start = (page - 1) * limit
|
|
return deepcopy(values[start : start + limit])
|
|
|
|
def _id(self):
|
|
self.next_id += 1
|
|
return self.next_id
|
|
|
|
@staticmethod
|
|
def _by_id(values, path):
|
|
wanted = int(path.rsplit("/", 1)[1])
|
|
return next(value for value in values if value["id"] == wanted)
|
|
|
|
@staticmethod
|
|
def assert_state_all(query):
|
|
if (query or {}).get("state") != "all":
|
|
raise AssertionError("milestone reads must request state=all")
|
|
|
|
|
|
class ProvisionerTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp.name)
|
|
(self.root / "docs/wiki").mkdir(parents=True)
|
|
(self.root / "docs/wiki/Home.md").write_text("# Rune\n\nCanonical wiki.\n", encoding="utf-8")
|
|
self.manifest = deepcopy(DESIRED)
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def provisioner(self, transport):
|
|
return Provisioner(self.manifest, transport, repository_root=self.root, page_size=2)
|
|
|
|
def converged_state(self):
|
|
branch = deepcopy(self.manifest["branch_protection"])
|
|
branch["rule_name"] = branch.pop("branch_name")
|
|
labels = [{"id": index + 1, **value} for index, value in enumerate(self.manifest["labels"])]
|
|
milestones = [{"id": 20, **self.manifest["milestones"][0]}]
|
|
wiki = {"Home": {"title": "Home", "content": "# Rune\n\nCanonical wiki.\n"}}
|
|
return {
|
|
"branch": branch,
|
|
"labels": labels,
|
|
"milestones": milestones,
|
|
"wiki": wiki,
|
|
}
|
|
|
|
def test_empty_state_reports_every_managed_object_and_check_is_get_only(self):
|
|
transport = FakeTransport()
|
|
drift = self.provisioner(transport).check()
|
|
self.assertEqual(
|
|
{item.kind for item in drift},
|
|
{"branch protection", "label", "milestone", "wiki page"},
|
|
)
|
|
self.assertTrue(all(call[0] == "GET" for call in transport.calls))
|
|
|
|
def test_apply_creates_empty_state_and_verifies_convergence(self):
|
|
transport = FakeTransport()
|
|
result = self.provisioner(transport).apply()
|
|
self.assertEqual(result, [])
|
|
self.assertIn("Home", transport.wiki)
|
|
wiki_write = next(
|
|
call for call in transport.calls if call[0] == "POST" and call[1] == "/api/v1/repos/conductor/rune/wiki/new"
|
|
)
|
|
self.assertEqual(
|
|
base64.b64decode(wiki_write[3]["content_base64"]).decode(),
|
|
"# Rune\n\nCanonical wiki.\n",
|
|
)
|
|
|
|
def test_apply_updates_drift_but_preserves_unmanaged_objects(self):
|
|
state = self.converged_state()
|
|
state["branch"]["enable_force_push"] = True
|
|
state["labels"][0]["color"] = "ffffff"
|
|
state["labels"].append({"id": 99, "name": "manual", "color": "abcdef", "description": "keep"})
|
|
state["milestones"][0]["description"] = "stale"
|
|
state["milestones"].append({"id": 98, "title": "Unmanaged", "description": "keep", "state": "closed"})
|
|
state["wiki"]["Home"]["content"] = "stale"
|
|
state["wiki"]["Notes"] = {"title": "Notes", "content": "keep"}
|
|
transport = FakeTransport(state)
|
|
self.assertEqual(self.provisioner(transport).apply(), [])
|
|
self.assertIn("manual", {label["name"] for label in transport.labels})
|
|
self.assertIn("Unmanaged", {milestone["title"] for milestone in transport.milestones})
|
|
self.assertIn("Notes", transport.wiki)
|
|
|
|
def test_converged_check_and_second_apply_have_no_writes(self):
|
|
transport = FakeTransport(self.converged_state())
|
|
self.assertEqual(self.provisioner(transport).check(), [])
|
|
transport.calls.clear()
|
|
self.assertEqual(self.provisioner(transport).apply(), [])
|
|
self.assertTrue(all(call[0] == "GET" for call in transport.calls))
|
|
|
|
def test_wiki_base64_response_is_decoded_and_does_not_trigger_a_write(self):
|
|
state = self.converged_state()
|
|
state["branch"]["enable_force_push"] = True
|
|
content = state["wiki"]["Home"].pop("content")
|
|
state["wiki"]["Home"]["content_base64"] = base64.b64encode(content.encode()).decode()
|
|
transport = FakeTransport(state)
|
|
self.assertEqual(self.provisioner(transport).apply(), [])
|
|
wiki_writes = [call for call in transport.calls if call[0] != "GET" and "/wiki/" in call[1]]
|
|
self.assertEqual(wiki_writes, [])
|
|
|
|
def test_wiki_slug_is_url_encoded_as_one_path_segment(self):
|
|
source = self.root / "docs/wiki/Ops FAQ.md"
|
|
source.write_text("# Ops FAQ\n", encoding="utf-8")
|
|
self.manifest["wiki_pages"] = [
|
|
{"title": "Ops FAQ", "slug": "Ops / FAQ", "source": "docs/wiki/Ops FAQ.md"},
|
|
]
|
|
state = self.converged_state()
|
|
state["wiki"] = {"Ops / FAQ": {"title": "Ops FAQ", "content": "# Ops FAQ\n"}}
|
|
transport = FakeTransport(state)
|
|
|
|
self.assertEqual(self.provisioner(transport).check(), [])
|
|
self.assertIn(
|
|
(
|
|
"GET",
|
|
"/api/v1/repos/conductor/rune/wiki/page/Ops%20%2F%20FAQ",
|
|
None,
|
|
None,
|
|
),
|
|
transport.calls,
|
|
)
|
|
|
|
def test_paginated_list_endpoints_read_later_pages(self):
|
|
state = self.converged_state()
|
|
state["labels"] = [
|
|
{"id": 90, "name": "manual-a", "color": "aaaaaa", "description": ""},
|
|
{"id": 91, "name": "manual-b", "color": "bbbbbb", "description": ""},
|
|
*state["labels"],
|
|
]
|
|
state["milestones"] = [
|
|
{"id": 92, "title": "Old A", "description": "", "state": "closed"},
|
|
{"id": 93, "title": "Old B", "description": "", "state": "closed"},
|
|
*state["milestones"],
|
|
]
|
|
state["wiki"] = {
|
|
"Notes-A": {"title": "Notes A", "content": "a"},
|
|
"Notes-B": {"title": "Notes B", "content": "b"},
|
|
**state["wiki"],
|
|
}
|
|
transport = FakeTransport(state)
|
|
self.assertEqual(self.provisioner(transport).check(), [])
|
|
paged_paths = [call[1] for call in transport.calls if call[2] and call[2].get("page", 1) > 1]
|
|
self.assertTrue(any(path.endswith("/labels") for path in paged_paths))
|
|
self.assertTrue(any(path.endswith("/milestones") for path in paged_paths))
|
|
self.assertTrue(any(path.endswith("/wiki/pages") for path in paged_paths))
|
|
|
|
def test_branch_protections_reads_fifty_items_once_without_pagination(self):
|
|
state = self.converged_state()
|
|
|
|
class FiftyBranchProtectionsTransport(FakeTransport):
|
|
def request(self, method, path, *, query=None, payload=None):
|
|
if method == "GET" and path.endswith("/branch_protections"):
|
|
self.calls.append((method, path, deepcopy(query), deepcopy(payload)))
|
|
branch_reads = [call for call in self.calls if call[1].endswith("/branch_protections")]
|
|
if len(branch_reads) > 1:
|
|
raise AssertionError("branch protections endpoint must be read only once")
|
|
return [
|
|
deepcopy(self.branch),
|
|
*({"rule_name": f"unmanaged-{index}"} for index in range(49)),
|
|
]
|
|
return super().request(method, path, query=query, payload=payload)
|
|
|
|
transport = FiftyBranchProtectionsTransport(state)
|
|
|
|
self.assertEqual(self.provisioner(transport).check(), [])
|
|
branch_reads = [call for call in transport.calls if call[1].endswith("/branch_protections")]
|
|
self.assertEqual(
|
|
branch_reads,
|
|
[
|
|
("GET", "/api/v1/repos/conductor/rune/branch_protections", None, None),
|
|
],
|
|
)
|
|
|
|
def test_normalization_accepts_equivalent_colors_and_instants(self):
|
|
self.assertEqual(normalize_color("#D73A4A"), "d73a4a")
|
|
self.assertEqual(normalize_instant("2026-08-31T01:00:00+01:00"), "2026-08-31T00:00:00Z")
|
|
state = self.converged_state()
|
|
state["labels"][0]["color"] = "d73a4a"
|
|
state["milestones"][0]["due_on"] = "2026-08-31T01:00:00+01:00"
|
|
transport = FakeTransport(state)
|
|
self.assertEqual(self.provisioner(transport).check(), [])
|
|
|
|
def test_apply_repairs_malformed_live_label_color_and_milestone_date(self):
|
|
state = self.converged_state()
|
|
state["labels"][0]["color"] = "not-a-color"
|
|
state["milestones"][0]["due_on"] = "not-a-date"
|
|
transport = FakeTransport(state)
|
|
|
|
self.assertEqual(self.provisioner(transport).apply(), [])
|
|
self.assertEqual(transport.labels[0]["color"], "d73a4a")
|
|
self.assertEqual(transport.milestones[0]["due_on"], "2026-08-31T00:00:00Z")
|
|
|
|
def test_manifest_is_validated_before_credentials_are_loaded(self):
|
|
manifest_path = self.root / "state.json"
|
|
invalid = deepcopy(self.manifest)
|
|
invalid["labels"][1]["name"] = invalid["labels"][0]["name"]
|
|
manifest_path.write_text(json.dumps(invalid), encoding="utf-8")
|
|
loaded = []
|
|
with self.assertRaises(ManifestError):
|
|
run(
|
|
["--manifest", str(manifest_path), "--check"],
|
|
token_loader=lambda: loaded.append(True),
|
|
)
|
|
self.assertEqual(loaded, [])
|
|
|
|
def test_manifest_rejects_unsafe_or_missing_wiki_sources(self):
|
|
manifest_path = self.root / "state.json"
|
|
invalid = json.loads(TRACKED_MANIFEST.read_text(encoding="utf-8"))
|
|
invalid["wiki_pages"][0]["source"] = "../secret"
|
|
manifest_path.write_text(json.dumps(invalid), encoding="utf-8")
|
|
with self.assertRaises(ManifestError):
|
|
load_manifest(manifest_path, repository_root=self.root)
|
|
|
|
def test_tracked_manifest_requires_all_managed_object_counts(self):
|
|
manifest = json.loads(TRACKED_MANIFEST.read_text(encoding="utf-8"))
|
|
manifest["labels"].pop()
|
|
manifest_path = self.root / "state.json"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
for page in manifest["wiki_pages"]:
|
|
source = self.root / page["source"]
|
|
source.parent.mkdir(parents=True, exist_ok=True)
|
|
source.write_text("wiki", encoding="utf-8")
|
|
with self.assertRaisesRegex(ManifestError, "exactly 11 labels"):
|
|
load_manifest(manifest_path, repository_root=self.root)
|
|
|
|
def test_manifest_requires_complete_branch_protection_policy(self):
|
|
manifest = json.loads(TRACKED_MANIFEST.read_text(encoding="utf-8"))
|
|
manifest["branch_protection"].pop("enable_force_push")
|
|
manifest_path = self.root / "state.json"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
for page in manifest["wiki_pages"]:
|
|
source = self.root / page["source"]
|
|
source.parent.mkdir(parents=True, exist_ok=True)
|
|
source.write_text("wiki", encoding="utf-8")
|
|
with self.assertRaisesRegex(ManifestError, "enable_force_push"):
|
|
load_manifest(manifest_path, repository_root=self.root)
|
|
|
|
def test_manifest_rejects_malformed_label_colors_and_milestone_dates(self):
|
|
cases = (
|
|
("labels", "color", "not-a-color", "invalid label color"),
|
|
("milestones", "due_on", "not-a-date", "invalid RFC3339 instant"),
|
|
)
|
|
for collection, field, value, message in cases:
|
|
with self.subTest(field=field):
|
|
manifest = json.loads(TRACKED_MANIFEST.read_text(encoding="utf-8"))
|
|
manifest[collection][0][field] = value
|
|
manifest_path = self.root / f"invalid-{field}.json"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
for page in manifest["wiki_pages"]:
|
|
source = self.root / page["source"]
|
|
source.parent.mkdir(parents=True, exist_ok=True)
|
|
source.write_text("wiki", encoding="utf-8")
|
|
with self.assertRaisesRegex(ManifestError, message):
|
|
load_manifest(manifest_path, repository_root=self.root)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|