[verified] Add fail-closed rework provider readback adapter
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
DIRECTORY = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts'
|
||||
ORIGIN = 'https://gitea.jsfamily.quest'
|
||||
T0 = '2026-01-01T00:00:00Z'
|
||||
T1 = '2026-01-02T00:00:00Z'
|
||||
|
||||
|
||||
class ReworkReadbackTests(unittest.TestCase):
|
||||
def load(self, name):
|
||||
path = DIRECTORY / (name + '.py')
|
||||
self.assertTrue(path.is_file(), 'canonical readback core missing')
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def setUp(self):
|
||||
self.legacy = self.load('rework_comment')
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
data = {'owner':'acme','repository':'demo','issue':7,'pr':9,'review':301,
|
||||
'reviewed_head':'a'*40,'final_head':'b'*40,'contract_sha256':'c'*64,
|
||||
'result':'partial','findings':[{'id':'F-1','status':'resolved','classification':'valid_fix',
|
||||
'changes':'bounded fix','verification':'local checks pass','reason':'regression'}],
|
||||
'local_checks':'pass','runtime_browser':'not applicable',
|
||||
'commit_status':'success','actions_runs':'없음'}
|
||||
(root/'input.json').write_text(json.dumps(data))
|
||||
self.legacy.do_render(Namespace(input=str(root/'input.json'), no_delegated_evidence=True,
|
||||
delegation_adoption=None, comment=str(root/'comment.md'), expected=str(root/'expected.json')))
|
||||
self.expected = json.loads((root/'expected.json').read_text())
|
||||
self.contract = {'schema':'gitea-agent.rework-provider/v1','version':'1.24.6',
|
||||
'aggregate':{'pr':{'field':'comments','components':['top_level','reviews','inline']},
|
||||
'pr_issue':{'field':'comments','components':['top_level','reviews','inline']}},
|
||||
'inline':{'mode':'per_review','field':'review_comments','components':['reviews','inline']}}
|
||||
self.repo = {'id':3,'full_name':'acme/demo','html_url':ORIGIN+'/acme/demo'}
|
||||
self.source = {'id':707,'number':7,'html_url':ORIGIN+'/acme/demo/issues/7',
|
||||
'body':'source contract','title':'Source','state':'open','comments':0,'updated_at':T0}
|
||||
self.pr = {'id':99,'number':9,'html_url':ORIGIN+'/acme/demo/pulls/9',
|
||||
'url':ORIGIN+'/acme/demo/pulls/9','state':'open',
|
||||
'head':{'sha':'b'*40,'repo':{'id':12,'full_name':'contributor/demo'}},
|
||||
'base':{'repo':copy.deepcopy(self.repo)},'title':'PR','body':'Closes #7',
|
||||
'comments':1,'review_comments':1,'updated_at':T0}
|
||||
self.underlying = {'id':99,'number':9,'html_url':self.pr['html_url'],
|
||||
'pull_request':{'html_url':self.pr['html_url'],'merged':False},
|
||||
'title':'PR','body':'Closes #7','state':'open','comments':1,'updated_at':T0}
|
||||
self.reviews = [{'id':301,'commit_id':'a'*40,'comments_count':0,'state':'REQUEST_CHANGES',
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
self.comments = []
|
||||
self.source_comments = []
|
||||
self.inline = []
|
||||
self.overrides = {}
|
||||
self.calls = []
|
||||
for record in (self.source, self.pr, self.underlying):
|
||||
record.update(created_at=T0, labels=[], assignees=[], milestone=None,
|
||||
user={'id':22,'login':'genichin'} if record is self.source
|
||||
else {'id':42,'login':'gitea-impl-agent'})
|
||||
self.pr['base'].update(ref='main', label='main', sha='d'*40, repo_id=3)
|
||||
self.pr['head'].update(ref='fix/issue-7', label='contributor:fix/issue-7', repo_id=12)
|
||||
self.reviews[0].update(body='Fix F-1', submitted_at=T0, updated_at=T0,
|
||||
stale=True, dismissed=False, html_url=self.pr['html_url']+'#pullreview-301',
|
||||
pull_request_url=self.pr['html_url'])
|
||||
|
||||
def request(self, path):
|
||||
self.calls.append(path)
|
||||
if path in self.overrides: return copy.deepcopy(self.overrides[path])
|
||||
parsed = urlsplit(path)
|
||||
objects = {'/user':{'id':42,'login':'gitea-impl-agent'}, '/version':{'version':'1.24.6'},
|
||||
'/repos/acme/demo':self.repo, '/repos/acme/demo/issues/7':self.source,
|
||||
'/repos/acme/demo/pulls/9':self.pr, '/repos/acme/demo/issues/9':self.underlying}
|
||||
if parsed.path in objects: return copy.deepcopy(objects[parsed.path]), {}, 200
|
||||
collections = {'/repos/acme/demo/issues/7/comments':self.source_comments,
|
||||
'/repos/acme/demo/issues/9/comments':self.comments,
|
||||
'/repos/acme/demo/pulls/9/reviews':self.reviews,
|
||||
'/repos/acme/demo/pulls/9/reviews/301/comments':self.inline}
|
||||
if parsed.path not in collections: raise AssertionError('unexpected GET endpoint')
|
||||
rows = collections[parsed.path]
|
||||
page = int(parse_qs(parsed.query)['page'][0])
|
||||
return copy.deepcopy(rows[(page-1)*50:page*50]), {'X-Total-Count':str(len(rows))}, 200
|
||||
|
||||
def adapter(self):
|
||||
module = self.load('rework_readback')
|
||||
try:
|
||||
return module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
|
||||
provider_contract=self.contract)
|
||||
except Exception as error:
|
||||
self.fail('canonical CLI transport contract rejected: '+str(error))
|
||||
|
||||
def add_result(self):
|
||||
self.comments.append({'id':501,'html_url':self.pr['html_url']+'#issuecomment-501',
|
||||
'body':self.expected['body'],'created_at':T1,'updated_at':T1,
|
||||
'user':{'id':42,'login':'gitea-impl-agent'}})
|
||||
self.pr['comments'] += 1
|
||||
self.underlying['comments'] += 1
|
||||
self.pr['updated_at'] = self.underlying['updated_at'] = T1
|
||||
|
||||
def test_snapshot_rejects_wrong_provider_bindings(self):
|
||||
cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'),
|
||||
('repo',['html_url'],'https://foreign.invalid/acme/demo'),
|
||||
('source',['number'],8), ('source',['id'],True),
|
||||
('source',['html_url'],ORIGIN+'/elsewhere/demo/issues/7'),
|
||||
('source',['pull_request'],{}), ('pr',['number'],10), ('pr',['id'],True),
|
||||
('pr',['url'],'https://foreign.invalid/api/v1/repos/acme/demo/pulls/9'),
|
||||
('pr',['html_url'],ORIGIN+'/elsewhere/demo/pulls/9'),
|
||||
('pr',['head','sha'],'c'*40), ('pr',['base','repo','id'],4),
|
||||
('pr',['state'],'closed'), ('underlying',['number'],10),
|
||||
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
|
||||
error = self.load('rework_readback').ReadbackError
|
||||
# Each load has a distinct exception class; assert ValueError at the public boundary.
|
||||
self.assertTrue(issubclass(error, ValueError))
|
||||
for attribute, keys, value in cases:
|
||||
with self.subTest(attribute=attribute, keys=keys):
|
||||
original = copy.deepcopy(getattr(self, attribute))
|
||||
item = getattr(self, attribute)
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
try:
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
finally: setattr(self, attribute, original)
|
||||
|
||||
def test_pagination_complete_51_and_rejects_bad_pages(self):
|
||||
self.comments = [{'id':600+i,'body':'existing '+str(i),'created_at':T0,'updated_at':T0,
|
||||
'user':{'id':8,'login':'other'}} for i in range(51)]
|
||||
self.pr['comments'] = self.underlying['comments'] = 52
|
||||
snapshot = self.adapter().snapshot(self.expected)
|
||||
self.assertEqual(snapshot['normalized_counts']['top_level'], 51)
|
||||
self.assertEqual(len(snapshot['raw']['comments']['pages']), 2)
|
||||
first = '/repos/acme/demo/issues/9/comments?page=1&limit=50'
|
||||
second = '/repos/acme/demo/issues/9/comments?page=2&limit=50'
|
||||
invalid = [(second,[self.comments[0]],{'X-Total-Count':'51'},200),
|
||||
(second,[self.comments[-1]],{'X-Total-Count':'52'},200),
|
||||
(first,None,{},200), (first,[],{'X-Total-Count':'0','x-total-count':'0'},200),
|
||||
(first,[],{'X-Total-Count':'0','Link':'<https://foreign.invalid/comments?page=2&limit=50>; rel="next"'},200),
|
||||
(first,self.comments[:50],{},200),
|
||||
(first,[{'id':True}],{'X-Total-Count':'1'},200),
|
||||
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
|
||||
(first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)]
|
||||
for path, rows, headers, status in invalid:
|
||||
with self.subTest(path=path, headers=headers, status=status):
|
||||
self.overrides = {path:(rows,headers,status)}
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
self.overrides = {}
|
||||
|
||||
def test_counts_only_inline_fallback_is_explicit_and_crosschecked(self):
|
||||
self.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
self.reviews[0]['comments_count'] = 3
|
||||
self.pr['comments'] = self.underlying['comments'] = self.pr['review_comments'] = 4
|
||||
endpoint = '/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50'
|
||||
self.overrides = {endpoint:(None,{},404)}
|
||||
try: snapshot = self.adapter().snapshot(self.expected)
|
||||
except ValueError as error: self.fail('configured counts-only fallback missing: '+str(error))
|
||||
self.assertEqual(snapshot['normalized_counts']['inline'], 3)
|
||||
self.assertEqual(snapshot['fallback'], 'counts_only')
|
||||
self.assertEqual(snapshot['raw']['inline']['301']['rows'], [])
|
||||
self.pr['review_comments'] = 5
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
self.pr['review_comments'] = 4
|
||||
for status, payload in ((403,None),(302,None),(200,None)):
|
||||
self.overrides = {endpoint:(payload,{},status)}
|
||||
with self.subTest(status=status):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
self.contract['inline']['mode'] = 'per_review'
|
||||
self.overrides = {endpoint:(None,{},404)}
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_preflight_rejects_unbound_contract_and_target_before_GET(self):
|
||||
module = self.load('rework_readback')
|
||||
changes = [(['schema'],'unsupported'), (['version'],True),
|
||||
(['aggregate','pr','components'],['inline']),
|
||||
(['aggregate','pr','components'],['top_level','top_level']),
|
||||
(['aggregate','pr_issue','field'],'other'),
|
||||
(['inline','mode'],'automatic'), (['inline','components'],['other'])]
|
||||
for keys, value in changes:
|
||||
contract = copy.deepcopy(self.contract)
|
||||
item = contract
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
with self.subTest(contract=keys):
|
||||
with self.assertRaises(ValueError):
|
||||
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
|
||||
provider_contract=contract)
|
||||
self.assertEqual(self.calls, [])
|
||||
for keys, value in ((['target','owner'],'acme/../elsewhere'),
|
||||
(['target','issue'],9), (['review'],True), (['head'],'B'*40),
|
||||
(['body'],'forged body')):
|
||||
expected = copy.deepcopy(self.expected)
|
||||
item = expected
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
self.calls = []
|
||||
with self.subTest(expected=keys):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(expected)
|
||||
self.assertEqual(self.calls, [], 'invalid expectation reached provider')
|
||||
|
||||
def test_snapshot_detects_drift_during_collection(self):
|
||||
original = self.request
|
||||
endpoints = ['/user','/version','/repos/acme/demo','/repos/acme/demo/issues/7',
|
||||
'/repos/acme/demo/pulls/9','/repos/acme/demo/issues/9',
|
||||
'/repos/acme/demo/pulls/9/reviews']
|
||||
for endpoint in endpoints:
|
||||
counts = {}
|
||||
def changing(path):
|
||||
value, headers, status = original(path)
|
||||
key = urlsplit(path).path
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
if key == endpoint and counts[key] > 1:
|
||||
if type(value) is list: value[0]['state'] = 'APPROVED'
|
||||
else: value['unexpected_drift'] = True
|
||||
return value, headers, status
|
||||
self.request = changing
|
||||
try:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
with self.assertRaisesRegex(ValueError, 'drift'):
|
||||
self.adapter().snapshot(self.expected)
|
||||
finally: self.request = original
|
||||
|
||||
def test_normalization_rejects_unrelated_delta_and_forged_pages(self):
|
||||
adapter = self.adapter()
|
||||
baseline = adapter.snapshot(self.expected)
|
||||
self.add_result()
|
||||
after = adapter.snapshot(self.expected)
|
||||
# Prove this exact clean fixture is admitted before adversarial mutation.
|
||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
|
||||
(['target','owner'],'other'), (['provider','version'],'different'),
|
||||
(['expected_sha256'],'0'*64), (['raw','identity','login'],'other'),
|
||||
(['raw','source_issue','body'],'unrelated edit'),
|
||||
(['raw','reviews','rows',0,'state'],'APPROVED'),
|
||||
(['raw','pr','head','sha'],'c'*40), (['raw','pr','title'],'unrelated edit'),
|
||||
(['raw','pr','updated_at'],'2026-01-03T00:00:00Z'),
|
||||
(['raw','comments','pages',0,'path'],'/repos/elsewhere/demo/issues/9/comments?page=1&limit=50'),
|
||||
(['raw','comments','pages',0,'count'],0),
|
||||
(['raw','comments','rows',0,'body'],'forged body'),
|
||||
(['raw','comments','rows',0,'user','id'],8),
|
||||
(['raw','comments','rows',0,'created_at'],'2025-01-01T00:00:00Z'),
|
||||
(['raw','comments','rows',0,'updated_at'],'2026-01-03T00:00:00Z')]
|
||||
for keys, value in cases:
|
||||
changed = copy.deepcopy(after)
|
||||
item = changed
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
with self.subTest(keys=keys):
|
||||
with self.assertRaises(ValueError):
|
||||
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
|
||||
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'reuse')
|
||||
|
||||
def test_existing_comment_rows_are_immutable(self):
|
||||
self.comments = [{'id':400,'body':'prior comment','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':8,'login':'other'}}]
|
||||
self.pr['comments'] = self.underlying['comments'] = 2
|
||||
adapter = self.adapter()
|
||||
baseline = adapter.snapshot(self.expected)
|
||||
self.add_result()
|
||||
after = adapter.snapshot(self.expected)
|
||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
after['raw']['comments']['rows'][0]['body'] = 'changed prior comment'
|
||||
with self.assertRaisesRegex(ValueError, 'existing comment'):
|
||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
|
||||
def test_snapshot_validates_captured_rows_before_persisting(self):
|
||||
self.add_result()
|
||||
self.adapter().snapshot(self.expected)
|
||||
for keys, value in ((['body'],True), (['user','id'],True),
|
||||
(['created_at'],'2026-01-02T00:00:00'),
|
||||
(['updated_at'],T0)):
|
||||
saved = copy.deepcopy(self.comments)
|
||||
item = self.comments[0]
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
with self.subTest(keys=keys):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
self.comments = saved
|
||||
self.source['updated_at'] = 'not a timestamp'
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_live_bridge_round_trip_over_local_HTTP(self):
|
||||
import contextlib
|
||||
import http.server
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import urllib.request
|
||||
from unittest.mock import patch
|
||||
cli = self.load('rework_readback_cli')
|
||||
root = Path(self.temp.name)
|
||||
profile = root/'profile'
|
||||
profile.mkdir(mode=0o700)
|
||||
secret = 'synthetic-fixture-' + 'q'*40
|
||||
(profile/'.env').write_text('GITEA_TOKEN='+secret+'\n')
|
||||
(profile/'.env').chmod(0o600)
|
||||
(root/'provider.json').write_text(json.dumps(self.contract))
|
||||
contract_hash = hashlib.sha256((root/'provider.json').read_bytes()).hexdigest()
|
||||
requests = []
|
||||
fixture = self
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args): pass
|
||||
def do_GET(self):
|
||||
requests.append((self.command, self.headers.get('Authorization') == 'token '+secret))
|
||||
data, headers, status = fixture.request(self.path[len('/api/v1'):])
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type','application/json')
|
||||
for key, value in headers.items(): self.send_header(key,value)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
inner = urllib.request.build_opener(cli.NoRedirect())
|
||||
class Proxy:
|
||||
def open(self, request, timeout):
|
||||
forwarded = urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+request.selector,
|
||||
headers=dict(request.header_items()), method=request.get_method())
|
||||
response = inner.open(forwarded, timeout=timeout)
|
||||
class Response:
|
||||
status = response.status
|
||||
headers = response.headers
|
||||
def geturl(self): return request.full_url
|
||||
def read1(self, n): return response.read1(n)
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): response.close()
|
||||
return Response()
|
||||
transport = cli.RestTransport
|
||||
common = dict(expected=str(root/'expected.json'), provider_contract=str(root/'provider.json'),
|
||||
provider_contract_sha256=contract_hash, actor_id=42)
|
||||
baseline_path, receipt_path = root/'baseline.json', root/'receipt.json'
|
||||
try:
|
||||
with patch.dict(os.environ, {'HERMES_HOME':str(profile)}), patch.object(cli,'RestTransport',
|
||||
side_effect=lambda credential: transport(credential, opener=Proxy())), contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
cli.run_live(Namespace(cmd='snapshot', output=str(baseline_path), **common), self.legacy.verify_readbacks)
|
||||
baseline_hash = hashlib.sha256(baseline_path.read_bytes()).hexdigest()
|
||||
self.add_result()
|
||||
args = Namespace(cmd='verify-live', baseline=str(baseline_path), baseline_sha256=baseline_hash,
|
||||
action='create', output=str(receipt_path), **common)
|
||||
cli.run_live(args, self.legacy.verify_readbacks)
|
||||
receipt = json.loads(receipt_path.read_text())
|
||||
self.assertEqual(receipt['status'],'verified')
|
||||
self.assertEqual(receipt['comment_id'],501)
|
||||
self.assertEqual(receipt['readback_provenance']['kind'],'fresh_authenticated_GET')
|
||||
original = receipt_path.read_bytes()
|
||||
self.source['title'] = 'unrelated provider edit'
|
||||
with self.assertRaises(ValueError): cli.run_live(args, self.legacy.verify_readbacks)
|
||||
self.assertEqual(receipt_path.read_bytes(), original)
|
||||
self.assertNotIn(secret, stdout.getvalue())
|
||||
self.assertNotIn(secret, receipt_path.read_text())
|
||||
self.assertTrue(requests)
|
||||
self.assertEqual(set(method for method, _ in requests), {'GET'})
|
||||
self.assertTrue(all(authenticated for _, authenticated in requests))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
def test_underlying_relation_requires_native_html_url(self):
|
||||
self.adapter().snapshot(self.expected)
|
||||
self.underlying['pull_request'].pop('html_url')
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_non_next_Link_relations_are_coherent_and_preserved(self):
|
||||
endpoint = '/repos/acme/demo/issues/7/comments'
|
||||
path = endpoint+'?page=1&limit=50'
|
||||
for relation, page in (('last',21), ('prev',1), ('first',2)):
|
||||
self.overrides = {path:([],{'X-Total-Count':'0','Link':
|
||||
'</api/v1'+endpoint+'?page='+str(page)+'&limit=50>; rel="'+relation+'"'},200)}
|
||||
with self.subTest(relation=relation):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
header = '</api/v1'+endpoint+'?page=1&limit=50>; rel="last"'
|
||||
self.overrides = {path:([],{'X-Total-Count':'0','Link':header},200)}
|
||||
snapshot = self.adapter().snapshot(self.expected)
|
||||
self.assertEqual(snapshot['raw']['source_comments']['pages'][0].get('link'), header)
|
||||
|
||||
def test_pagination_link_rejects_unbalanced_relation_quotes(self):
|
||||
endpoint = '/repos/acme/demo/issues/7/comments'
|
||||
path = endpoint+'?page=1&limit=50'
|
||||
for relation in ('"last', 'last"'):
|
||||
self.overrides = {path:([],{'X-Total-Count':'0','Link':
|
||||
'</api/v1'+endpoint+'?page=1&limit=50>; rel='+relation},200)}
|
||||
with self.subTest(relation=relation):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
|
||||
# Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6).
|
||||
self.pr['url'] = self.pr['html_url']
|
||||
self.contract['inline']['components'] = ['inline']
|
||||
self.pr.pop('review_comments')
|
||||
try: snapshot = self.adapter().snapshot(self.expected)
|
||||
except ValueError as error: self.fail('native Gitea wire shape rejected: '+str(error))
|
||||
self.assertEqual(snapshot['normalized_counts']['inline'], 0)
|
||||
self.assertEqual(snapshot['fallback'], 'none')
|
||||
self.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':(None,{},404)}
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_boolean_actor_is_rejected_before_other_GETs(self):
|
||||
module = self.load('rework_readback')
|
||||
self.overrides = {'/user':({'id':True,'login':'gitea-impl-agent'}, {}, 200)}
|
||||
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=1, provider_contract=self.contract)
|
||||
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
|
||||
self.assertEqual(self.calls, ['/user'])
|
||||
|
||||
def test_required_native_metadata_and_PR_author_are_verified(self):
|
||||
self.adapter().snapshot(self.expected)
|
||||
cases = [('pr',['user'],None), ('pr',['user','id'],43),
|
||||
('pr',['user','login'],'different'), ('source',['labels'],True),
|
||||
('source',['assignees'],True), ('source',['milestone'],[]),
|
||||
('pr',['base','ref'],None), ('pr',['head','repo_id'],99),
|
||||
('reviews',[0,'state'],None), ('reviews',[0,'body'],None),
|
||||
('reviews',[0,'user','login'],'unapproved'),
|
||||
('reviews',[0,'submitted_at'],None), ('reviews',[0,'dismissed'],True),
|
||||
('reviews',[0,'stale'],'true')]
|
||||
for name, keys, value in cases:
|
||||
original = copy.deepcopy(getattr(self,name))
|
||||
item = getattr(self,name)
|
||||
for key in keys[:-1]: item = item[key]
|
||||
item[keys[-1]] = value
|
||||
with self.subTest(name=name, keys=keys):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
setattr(self,name,original)
|
||||
for name, field in (('source','labels'), ('source','assignees'), ('source','milestone'),
|
||||
('underlying','user'), ('reviews','submitted_at')):
|
||||
original = copy.deepcopy(getattr(self,name))
|
||||
record = getattr(self,name)[0] if name == 'reviews' else getattr(self,name)
|
||||
record.pop(field)
|
||||
with self.subTest(missing=name+'.'+field):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
setattr(self,name,original)
|
||||
|
||||
def test_source_timestamp_change_is_not_admitted_as_causal_proof(self):
|
||||
adapter = self.adapter()
|
||||
baseline = adapter.snapshot(self.expected)
|
||||
self.add_result()
|
||||
after = adapter.snapshot(self.expected)
|
||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
after['raw']['source_issue']['updated_at'] = T1
|
||||
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
|
||||
def test_native_nullable_assignees_and_milestone_without_number(self):
|
||||
for record in (self.source,self.pr,self.underlying):
|
||||
record['assignees'] = None
|
||||
record['milestone'] = {'id':55,'title':'Native milestone','state':'open'}
|
||||
try: self.adapter().snapshot(self.expected)
|
||||
except ValueError as error: self.fail('native nullable-assignee/milestone shape rejected: '+str(error))
|
||||
self.source.pop('assignees')
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_closing_keyword_must_match_canonical_Closes(self):
|
||||
self.adapter().snapshot(self.expected)
|
||||
self.pr['body'] = 'Fixes #7'
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
def test_renderer_to_existing_verifier_create_and_reuse(self):
|
||||
adapter = self.adapter()
|
||||
try: baseline = adapter.snapshot(self.expected)
|
||||
except Exception as error: self.fail('actual renderer expected rejected: '+str(error))
|
||||
self.assertEqual(self.calls[0], '/user')
|
||||
self.add_result()
|
||||
after = adapter.snapshot(self.expected)
|
||||
inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
|
||||
inputs['identity'], action='create', baseline_count=inputs['baseline_count'])
|
||||
self.assertEqual(receipt['status'], 'verified')
|
||||
self.assertEqual(receipt['comment_id'], 501)
|
||||
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
|
||||
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
|
||||
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
|
||||
@@ -0,0 +1,194 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py'
|
||||
|
||||
|
||||
class ReworkReadbackTransportTests(unittest.TestCase):
|
||||
def module(self):
|
||||
path = SCRIPT.with_name('rework_readback_cli.py')
|
||||
self.assertTrue(path.is_file(), 'canonical live readback bridge is missing')
|
||||
spec = importlib.util.spec_from_file_location('transport_under_test', path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_role_token_requires_private_profile_file(self):
|
||||
module = self.module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||
source = home / '.env'
|
||||
source.write_text('GITEA_TOKEN=' + 's' * 48 + '\n')
|
||||
source.chmod(0o644)
|
||||
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||
source.chmod(0o600)
|
||||
self.assertEqual(module.load_role_token(home), 's' * 48)
|
||||
source.unlink()
|
||||
external = home / 'external'
|
||||
external.write_text('GITEA_TOKEN=' + 's' * 48)
|
||||
external.chmod(0o600)
|
||||
source.symlink_to(external)
|
||||
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||
|
||||
def test_http_transport_get_only_redirects_and_secret_echo_are_rejected(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'RestTransport', None)), 'read-only HTTP transport missing')
|
||||
import http.server
|
||||
import threading
|
||||
import urllib.request
|
||||
requests = []
|
||||
secret = 'z' * 48
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args): pass
|
||||
def do_GET(self):
|
||||
requests.append((self.path, self.headers.get('Authorization') == 'token ' + secret))
|
||||
if self.path == '/api/v1/version':
|
||||
self.send_response(302)
|
||||
self.send_header('Location', 'https://outside.invalid/user')
|
||||
self.end_headers()
|
||||
return
|
||||
body = (b'{"credential":"' + secret.encode() + b'"}'
|
||||
if self.path.endswith('/secret') else b'{"id":42,"login":"gitea-impl-agent"}')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
if self.path.endswith('/duplicate'):
|
||||
self.send_header('X-Total-Count', '1')
|
||||
self.send_header('X-Total-Count', '2')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs): return None
|
||||
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
inner = urllib.request.build_opener(NoRedirect())
|
||||
|
||||
class Proxy:
|
||||
def open(self, request, timeout):
|
||||
self_request = urllib.request.Request(
|
||||
'http://127.0.0.1:' + str(server.server_port) + request.selector,
|
||||
headers=dict(request.header_items()), method=request.get_method())
|
||||
response = inner.open(self_request, timeout=timeout)
|
||||
original = request.full_url
|
||||
class Response:
|
||||
status = response.status
|
||||
headers = response.headers
|
||||
def geturl(self): return original
|
||||
def read1(self, n): return response.read1(n)
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): response.close()
|
||||
return Response()
|
||||
|
||||
try:
|
||||
client = module.RestTransport(secret, opener=Proxy())
|
||||
data, _, status = client('/user')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['id'], 42)
|
||||
self.assertEqual(client('/version')[2], 302)
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
client('/repos/acme/widget/secret')
|
||||
self.assertNotIn(secret, str(raised.exception))
|
||||
with self.assertRaises(ValueError): client('/repos/acme/widget/duplicate')
|
||||
self.assertTrue(all(authenticated for _, authenticated in requests))
|
||||
self.assertEqual(len(requests), 4)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
def test_output_admission_preserves_receipt_on_failure_and_rejects_links(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'OutputFile', None)), 'pinned output writer missing')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
receipt = Path(directory) / 'receipt.json'
|
||||
receipt.write_text('original')
|
||||
receipt.chmod(0o600)
|
||||
with module.OutputFile(receipt): pass
|
||||
self.assertEqual(receipt.read_text(), 'original')
|
||||
with module.OutputFile(receipt) as output:
|
||||
output.write({'status': 'verified'})
|
||||
self.assertIn('verified', receipt.read_text())
|
||||
self.assertEqual(receipt.stat().st_mode & 0o777, 0o600)
|
||||
foreign = Path(directory) / 'foreign'
|
||||
foreign.write_text('preserve')
|
||||
receipt.unlink()
|
||||
receipt.symlink_to(foreign)
|
||||
with self.assertRaises(ValueError): module.OutputFile(receipt)
|
||||
self.assertEqual(foreign.read_text(), 'preserve')
|
||||
|
||||
def test_http_operation_budget_stops_before_next_get(self):
|
||||
module = self.module()
|
||||
from unittest.mock import patch
|
||||
clock = [0]
|
||||
calls = []
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
calls.append(request.full_url)
|
||||
class Response:
|
||||
status = 200
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
emitted = False
|
||||
def geturl(self): return request.full_url
|
||||
def read1(self, n):
|
||||
if self.emitted: return b''
|
||||
self.emitted = True
|
||||
return b'{"id":42}'
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
return Response()
|
||||
with patch.object(module.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||
client = module.RestTransport('b' * 48, opener=Opener())
|
||||
self.assertEqual(client('/user')[2], 200)
|
||||
clock[0] = 121
|
||||
with self.assertRaises(ValueError): client('/version')
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_live_bridge_rejects_unbound_provider_before_transport(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'run_live', None)), 'live verifier bridge missing')
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
expected = root / 'expected.json'
|
||||
expected.write_text('{}')
|
||||
contract = root / 'provider.json'
|
||||
contract.write_text('{"schema":"test-contract"}')
|
||||
trusted = hashlib.sha256(contract.read_bytes()).hexdigest()
|
||||
self.assertEqual(module.read_bound_json(contract, trusted)['schema'], 'test-contract')
|
||||
receipt = root / 'receipt.json'
|
||||
receipt.write_text('preserve')
|
||||
receipt.chmod(0o600)
|
||||
args = SimpleNamespace(cmd='snapshot', expected=str(expected),
|
||||
provider_contract=str(contract), provider_contract_sha256='0' * 64,
|
||||
actor_id=42, output=str(receipt))
|
||||
with patch.object(module, 'RestTransport') as transport:
|
||||
with patch.object(module, 'read_bound_json', wraps=module.read_bound_json) as reader:
|
||||
with self.assertRaises(ValueError): module.run_live(args, lambda *a, **k: None)
|
||||
self.assertGreaterEqual(reader.call_count, 2)
|
||||
transport.assert_not_called()
|
||||
self.assertEqual(receipt.read_text(), 'preserve')
|
||||
|
||||
def test_existing_command_exposes_readonly_snapshot(self):
|
||||
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'snapshot', '--help'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--provider-contract', result.stdout)
|
||||
self.assertIn('--actor-id', result.stdout)
|
||||
|
||||
def test_existing_command_exposes_live_verification(self):
|
||||
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'verify-live', '--help'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--baseline-sha256', result.stdout)
|
||||
Reference in New Issue
Block a user