Revision 2 live run passed 24/25; the clean merge hit Forgejo's 405 during the mergeability recheck. Revision 3 waits for mergeable, retries only 405 (max 3) and records status codes. 7 boundary tests, isolated rehearsal (27 checks) and the attended live run (26/26, verified, cleanup confirmed) pass. Revision 2 proposal and receipts retained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7 KiB
Python
142 lines
7 KiB
Python
"""Boundary regressions for receipt-scoped cleanup and credential forwarding."""
|
|
import importlib.util
|
|
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from unittest import mock
|
|
import urllib.request
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
spec = importlib.util.spec_from_file_location('routes', Path(__file__).with_name('accept_forge_routes.py'))
|
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
|
|
|
|
|
class CleanupTests(unittest.TestCase):
|
|
def test_replaced_or_changed_repository_is_not_deleted(self):
|
|
for change in ({'id': 18}, {'private': False}, {'description': 'changed'},
|
|
{'owner': {'login': 'someone-else'}}):
|
|
with self.subTest(change=change), tempfile.TemporaryDirectory() as directory:
|
|
name = 'custodian-routes-' + 'a' * 24
|
|
record = dict(id=17, name=name, private=True, description=name, owner={'login': 'tegwick'})
|
|
record.update(change)
|
|
class Backend:
|
|
user = 'tegwick'
|
|
scope = 'test'
|
|
def api(self, method, *args):
|
|
self_outer.assertEqual(method, 'GET', 'must not delete changed ownership')
|
|
return 200, json.dumps(record).encode()
|
|
self_outer = self
|
|
suite = m.Suite(Backend(), Path(directory) / 'receipt.json', name)
|
|
suite.created_id = 17
|
|
with self.assertRaises(ValueError):
|
|
suite.close()
|
|
|
|
def test_ambiguous_creation_never_deletes_by_name(self):
|
|
class Backend:
|
|
user = 'tegwick'
|
|
scope = 'test'
|
|
def api(self, *args):
|
|
raise AssertionError('unowned creation must not issue API calls')
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
suite = m.Suite(Backend(), Path(directory) / 'receipt.json', 'custodian-routes-' + 'a' * 24)
|
|
suite.evidence['creation_attempted'] = True
|
|
suite.close()
|
|
self.assertTrue(suite.evidence['creation_outcome_requires_reconciliation'])
|
|
self.assertEqual(suite.evidence['result'], 'unverified')
|
|
|
|
def test_generic_error_is_not_detection_evidence(self):
|
|
self.assertFalse(m.detected(b'{"message":"internal error"}'))
|
|
self.assertFalse(m.detected(b'{"status":"unverified"}'))
|
|
|
|
|
|
class MergeRetryTests(unittest.TestCase):
|
|
def suite(self, directory, merges):
|
|
calls = []
|
|
class Backend:
|
|
user = 'tegwick'
|
|
scope = 'test'
|
|
def api(self, method, path, data=None, authenticated=True):
|
|
calls.append(method)
|
|
if method == 'GET':
|
|
return 200, json.dumps({'mergeable': True}).encode()
|
|
return merges.pop(0), b'{}'
|
|
suite = m.Suite(Backend(), Path(directory) / 'receipt.json', 'custodian-routes-' + 'a' * 24)
|
|
return suite, calls
|
|
|
|
def test_recheck_405_is_waited_out_and_retried(self):
|
|
with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'):
|
|
suite, calls = self.suite(directory, [405, 200])
|
|
self.assertEqual(suite.clean_merge('/pulls/1'), 200)
|
|
self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 200])
|
|
self.assertEqual(calls, ['GET', 'POST', 'GET', 'POST'])
|
|
|
|
def test_other_refusals_are_not_retried_and_retries_are_bounded(self):
|
|
with tempfile.TemporaryDirectory() as directory, mock.patch.object(m.time, 'sleep'):
|
|
suite, _ = self.suite(directory, [409])
|
|
self.assertEqual(suite.clean_merge('/pulls/1'), 409)
|
|
self.assertEqual(suite.evidence['clean_merge_status_codes'], [409])
|
|
suite, _ = self.suite(directory, [405, 405, 405, 200])
|
|
self.assertEqual(suite.clean_merge('/pulls/1'), 405)
|
|
self.assertEqual(suite.evidence['clean_merge_status_codes'], [405, 405, 405])
|
|
|
|
|
|
class TransportTests(unittest.TestCase):
|
|
def test_http_redirect_does_not_forward_injected_credential(self):
|
|
hits = []
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
hits.append(self.path)
|
|
self.send_response(302)
|
|
self.send_header('Location', '/receiver')
|
|
self.end_headers()
|
|
def log_message(self, *args):
|
|
pass
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start()
|
|
try:
|
|
backend = object.__new__(m.Production)
|
|
backend.origin = 'http://127.0.0.1:' + str(server.server_port)
|
|
backend.token = 'unbound-test-value'
|
|
backend.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), m.NoRedirect())
|
|
code, _ = backend.api('GET', '/api/v1/user')
|
|
self.assertEqual(code, 302)
|
|
self.assertEqual(hits, ['/api/v1/user'])
|
|
finally:
|
|
server.shutdown(); server.server_close(); worker.join()
|
|
|
|
def test_handoff_is_silent_and_rejects_changed_source_before_fetch(self):
|
|
spec = importlib.util.spec_from_file_location('handoff', Path(__file__).with_name('run_forge_routes.py'))
|
|
wrapper = importlib.util.module_from_spec(spec); spec.loader.exec_module(wrapper)
|
|
for changed in (False, True):
|
|
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
proposal = {'repository_name': 'custodian-routes-' + 'a' * 24, 'source_sha256': {
|
|
n: hashlib.sha256((wrapper.HERE / n).read_bytes()).hexdigest()
|
|
for n in ('run_forge_routes.py', 'accept_forge_routes.py')}}
|
|
if changed:
|
|
proposal['source_sha256']['accept_forge_routes.py'] = '0' * 64
|
|
(root / 'proposal.json').write_text(json.dumps(proposal))
|
|
out, err = io.StringIO(), io.StringIO()
|
|
with mock.patch('sys.argv', ['handoff', '--proposal', str(root / 'proposal.json'),
|
|
'--receipt', str(root / 'result.json')]), mock.patch.object(
|
|
wrapper.subprocess, 'run', return_value=mock.Mock(returncode=5,
|
|
stdout=b'UNBOUND-CREDENTIAL-MARKER', stderr=b'UNBOUND-CREDENTIAL-MARKER')) as child:
|
|
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
result = wrapper.main()
|
|
self.assertEqual(out.getvalue() + err.getvalue(), '')
|
|
if changed:
|
|
child.assert_not_called()
|
|
self.assertEqual(result, 2)
|
|
else:
|
|
self.assertEqual(result, 5)
|
|
self.assertTrue(child.call_args.kwargs['capture_output'])
|
|
self.assertNotIn('UNBOUND-CREDENTIAL-MARKER', (root / 'result.transport.json').read_text())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|