323 lines
20 KiB
Python
323 lines
20 KiB
Python
import copy
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from harness.tests.spec import test_implementation_spec_review_support as fixture
|
|
|
|
|
|
class SourceIssueReferenceProjectionTests(unittest.TestCase):
|
|
def contract(self):
|
|
return {'version': {'version': '1.27.3'},
|
|
'source_issue_cross_reference_projection': True}
|
|
|
|
def snapshots(self):
|
|
before, after, review, result, paths = fixture.publication_fixture()
|
|
url = before['pr']['html_url']
|
|
issue_url = url.rsplit('/pulls/', 1)[0] + '/issues/3'
|
|
before['issue']['html_url'] = after['issue']['html_url'] = issue_url
|
|
pr_issue = {'id': 70, 'number': 7, 'html_url': url,
|
|
'repository': {'full_name': 'acme/repo'}, 'user': {'id': 99},
|
|
'state': 'open', 'title': 'implementation', 'body': 'Closes #3',
|
|
'comments': 2, 'updated_at': before['pr']['updated_at'],
|
|
'pull_request': {'merged': False, 'html_url': url}}
|
|
before['pr_issue'] = pr_issue
|
|
after['pr_issue'] = copy.deepcopy(pr_issue)
|
|
# Native top-level reviews change review_comments, not ordinary comments.
|
|
after['pr_issue'].update(updated_at=review['submitted_at'])
|
|
event = after['timeline'][0]
|
|
event['html_url'] = url + '#issuecomment-101'
|
|
event['issue_url'] = ''
|
|
event['assets'] = []
|
|
ref_comment = {key: copy.deepcopy(event[key]) for key in
|
|
('id', 'html_url', 'pull_request_url', 'issue_url', 'user',
|
|
'body', 'assets', 'created_at', 'updated_at')}
|
|
reference = {'id': 102, 'type': 'comment_ref', 'user': copy.deepcopy(review['user']),
|
|
'body': '', 'html_url': issue_url + '#issuecomment-102',
|
|
'issue_url': issue_url, 'pull_request_url': '',
|
|
'created_at': '2026-10-03T01:24:01Z',
|
|
'updated_at': '2026-10-03T01:24:01Z',
|
|
'ref_issue': copy.deepcopy(after['pr_issue']),
|
|
'ref_comment': ref_comment, 'ref_action': 'none', 'review_id': 0}
|
|
before['issue_timeline'] = []
|
|
after['issue_timeline'] = [reference]
|
|
after['issue']['updated_at'] = reference['created_at']
|
|
return before, after, review, result, paths
|
|
|
|
def test_exact_reference_allows_only_source_issue_timestamp(self):
|
|
before, after, review, result, paths = self.snapshots()
|
|
original = copy.deepcopy((before, after))
|
|
try:
|
|
actual = fixture.verify_fixture(before, after, review, result, paths, self.contract())
|
|
except fixture.mod.ValidationError as exc:
|
|
self.fail('exact review-bound source reference rejected: ' + str(exc))
|
|
self.assertEqual(actual['source_issue_reference_ids'], [102])
|
|
self.assertEqual((before, after), original)
|
|
after['pr']['updated_at'] = after['issue']['updated_at']
|
|
with self.assertRaisesRegex(fixture.mod.ValidationError, 'timestamp projection mismatch'):
|
|
fixture.verify_fixture(before, after, review, result, paths, self.contract())
|
|
|
|
def test_pr_issue_comments_stay_unchanged_while_review_count_increments(self):
|
|
for count in (0, 2):
|
|
before, after, review, result, paths = self.snapshots()
|
|
before['pr_issue']['comments'] = after['pr_issue']['comments'] = count
|
|
after['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(after['pr_issue'])
|
|
with self.subTest(count=count):
|
|
fixture.verify_fixture(before, after, review, result, paths, self.contract())
|
|
self.assertEqual(after['pr_issue']['comments'], before['pr_issue']['comments'])
|
|
self.assertEqual(after['pr']['review_comments'], before['pr']['review_comments'] + 1)
|
|
|
|
def test_pr_issue_comment_changes_and_invalid_counts_are_rejected(self):
|
|
baseline = self.snapshots()
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
# Reject both directions, including the old erroneous +1 projection.
|
|
for value in (1, 3, 4, -1, True, '2', None):
|
|
args = copy.deepcopy(baseline)
|
|
args[1]['pr_issue']['comments'] = value
|
|
args[1]['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(args[1]['pr_issue'])
|
|
with self.subTest(after=value), self.assertRaisesRegex(
|
|
fixture.mod.ValidationError, 'PR Issue count projection mismatch'):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
for value in (-1, True, '2', None):
|
|
args = copy.deepcopy(baseline)
|
|
args[0]['pr_issue']['comments'] = args[1]['pr_issue']['comments'] = value
|
|
args[1]['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(args[1]['pr_issue'])
|
|
with self.subTest(both=value), self.assertRaisesRegex(
|
|
fixture.mod.ValidationError, 'PR Issue count projection mismatch'):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
for side in (0, 1):
|
|
args = copy.deepcopy(baseline)
|
|
del args[side]['pr_issue']['comments']
|
|
with self.subTest(missing=side), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
|
|
def test_source_reference_does_not_relax_pr_review_comment_increment(self):
|
|
baseline = self.snapshots()
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
for value in (0, 2, -1, True, '1', None):
|
|
args = copy.deepcopy(baseline)
|
|
args[1]['pr']['review_comments'] = value
|
|
with self.subTest(after=value), self.assertRaisesRegex(
|
|
fixture.mod.ValidationError, 'review count projection mismatch'):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
|
|
def test_wrong_reference_identity_payload_or_timestamp_is_rejected(self):
|
|
baseline = self.snapshots()
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
mutations = [('type', 'comment'), ('user.id', 43), ('user.login', 'changed'),
|
|
('ref_issue.number', 8), ('ref_issue.repository.full_name', 'other/repo'),
|
|
('ref_issue.id', 71), ('ref_comment.id', 100), ('ref_comment.id', True),
|
|
('ref_comment.user.id', 43), ('ref_comment.body', 'other review'),
|
|
('ref_comment.created_at', '2026-10-03T01:24:01Z'),
|
|
('ref_comment.updated_at', '2026-10-03T01:24:01Z'),
|
|
('ref_comment.html_url', 'https://other.test/acme/repo/pulls/7#issuecomment-101'),
|
|
('ref_comment.pull_request_url', 'https://other.test/acme/repo/pulls/7'),
|
|
('ref_comment.assets', [{'id': 9}]), ('ref_comment.issue_url', 'wrong'),
|
|
('issue_url', 'https://example.test/acme/repo/issues/4'),
|
|
('html_url', 'https://example.test/acme/repo/issues/3#issuecomment-103'),
|
|
('pull_request_url', 'https://example.test/acme/repo/pulls/7'),
|
|
('ref_action', 'close'), ('review_id', True), ('body', 'unrelated'),
|
|
('created_at', '2026-10-03T01:23:59Z'), ('updated_at', '2026-10-03T01:24:02Z')]
|
|
for field, value in mutations:
|
|
before, after, review, result, paths = copy.deepcopy(baseline)
|
|
node = after['issue_timeline'][-1]
|
|
parts = field.split('.')
|
|
for key in parts[:-1]:
|
|
node = node[key]
|
|
node[parts[-1]] = value
|
|
with self.subTest(field=field, value=value), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(before, after, review, result, paths, self.contract())
|
|
for field in ('ref_issue', 'ref_comment', 'created_at', 'updated_at', 'user'):
|
|
before, after, review, result, paths = copy.deepcopy(baseline)
|
|
del after['issue_timeline'][-1][field]
|
|
with self.subTest(missing=field), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(before, after, review, result, paths, self.contract())
|
|
|
|
def test_opt_in_version_and_complete_evidence_are_required(self):
|
|
baseline = self.snapshots()
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
for contract in (None, {'version': {'version': '1.27.3'}},
|
|
{'version': {'version': '1.27.3'}, 'source_issue_cross_reference_projection': False},
|
|
{'version': {'version': '1.27.2'}, 'source_issue_cross_reference_projection': True}):
|
|
with self.subTest(contract=contract), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*baseline, contract)
|
|
for value in (1, 'true', None, [], {}):
|
|
contract = self.contract(); contract['source_issue_cross_reference_projection'] = value
|
|
with self.subTest(value=value), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*baseline, contract)
|
|
for side in (0, 1):
|
|
for field in ('issue_timeline', 'pr_issue'):
|
|
args = copy.deepcopy(baseline); del args[side][field]
|
|
with self.subTest(side=side, missing=field), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
|
|
def test_no_time_tolerance_or_other_field_relaxation(self):
|
|
baseline = self.snapshots()
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
for section, field, value in [('issue', 'updated_at', '2026-10-03T01:24:02Z'),
|
|
('issue', 'title', 'modified'), ('issue', 'body', 'new contract'),
|
|
('pr_issue', 'updated_at', '2026-10-03T01:24:01Z'),
|
|
('pr_issue', 'comments', 4), ('pr_issue', 'comments', True),
|
|
('pr_issue', 'body', 'modified'), ('pr_issue', 'id', True),
|
|
('pr_issue', 'number', 8), ('pr_issue', 'state', 'closed')]:
|
|
args = copy.deepcopy(baseline); args[1][section][field] = value
|
|
with self.subTest(section=section, field=field), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
for mode in ('missing', 'duplicate', 'competing'):
|
|
args = copy.deepcopy(baseline); events = args[1]['issue_timeline']
|
|
if mode == 'missing':
|
|
events.clear()
|
|
else:
|
|
extra = copy.deepcopy(events[-1])
|
|
if mode == 'competing':
|
|
extra.update(id=103, type='comment')
|
|
events.append(extra)
|
|
with self.subTest(mode=mode), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
|
|
def test_existing_source_records_preserve_every_field_except_bound_pr_issue_projection(self):
|
|
before, after, review, result, paths = self.snapshots()
|
|
old = {'id': 90, 'type': 'pull_ref', 'body': '',
|
|
'ref_issue': copy.deepcopy(before['pr_issue']), 'user': {'id': 99}}
|
|
before['issue_timeline'].append(old)
|
|
after['issue_timeline'].insert(0, copy.deepcopy(old))
|
|
after['issue_timeline'][0]['ref_issue'] = copy.deepcopy(after['pr_issue'])
|
|
baseline = (before, after, review, result, paths)
|
|
fixture.verify_fixture(*baseline, self.contract())
|
|
for field in ('body', 'user', 'ref_issue', 'missing', 'order'):
|
|
args = copy.deepcopy(baseline)
|
|
if field == 'missing':
|
|
args[1]['issue_timeline'].pop(0)
|
|
elif field == 'order':
|
|
args[1]['issue_timeline'].reverse()
|
|
elif field == 'ref_issue':
|
|
args[1]['issue_timeline'][0]['ref_issue']['comments'] = 5
|
|
else:
|
|
args[1]['issue_timeline'][0][field] = 'tampered'
|
|
with self.subTest(field=field), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
args = copy.deepcopy(baseline)
|
|
args[0]['issue_timeline'].append({'id': 89, 'body': 'ordinary'})
|
|
args[1]['issue_timeline'].insert(1, {'id': 89, 'body': 'ordinary'})
|
|
fixture.verify_fixture(*args, self.contract())
|
|
args[1]['issue_timeline'][1]['body'] = 'changed'
|
|
with self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(*args, self.contract())
|
|
|
|
def test_source_reference_timestamp_does_not_apply_to_historical_reviews(self):
|
|
before, after, review, result, paths = self.snapshots()
|
|
old = copy.deepcopy(review)
|
|
old.update(id=10, body='previous', state='REQUEST_CHANGES', commit_id='d' * 40,
|
|
updated_at=before['pr']['updated_at'])
|
|
before['reviews'] = [old]
|
|
after['reviews'].insert(0, copy.deepcopy(old))
|
|
after['reviews'][0].update(official=False, dismissed=True, updated_at=review['submitted_at'])
|
|
contract = self.contract(); contract['supersession_same_author_only'] = True
|
|
fixture.verify_fixture(before, after, review, result, paths, contract)
|
|
after['reviews'][0]['updated_at'] = after['issue']['updated_at']
|
|
with self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.verify_fixture(before, after, review, result, paths, contract)
|
|
|
|
def test_cli_uses_exact_reference_and_sanitizes_invalid_input(self):
|
|
from pathlib import Path
|
|
before, after, review, result, paths = self.snapshots()
|
|
with tempfile.TemporaryDirectory(dir='/opt/data/cache/scratch') as directory:
|
|
root = Path(directory)
|
|
data = {'before': before, 'after': after, 'exact-review': review, 'result': result,
|
|
'allowed-paths': sorted(paths), 'inline-comments': [], 'provider-contract': self.contract()}
|
|
command = [sys.executable, '-B', str(fixture.MODPATH), 'verify-publication',
|
|
'--reviewer-id', '42', '--expected-pr-url', before['pr']['html_url']]
|
|
for key, value in data.items():
|
|
path = root / (key + '.json')
|
|
path.write_text(json.dumps(value), encoding='utf-8')
|
|
command.extend(['--' + key, str(path)])
|
|
actual = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(actual.returncode, 0, actual.stderr)
|
|
self.assertEqual(json.loads(actual.stdout)['source_issue_reference_ids'], [102])
|
|
after['issue_timeline'][0]['ref_comment']['body'] = 'sensitive-value-do-not-echo'
|
|
(root / 'after.json').write_text(json.dumps(after), encoding='utf-8')
|
|
rejected = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(rejected.returncode, 2)
|
|
self.assertEqual(json.loads(rejected.stderr), {'error': 'invalid input'})
|
|
self.assertEqual(rejected.stdout, '')
|
|
self.assertNotIn('sensitive-value-do-not-echo', rejected.stdout + rejected.stderr)
|
|
|
|
def test_collector_rejects_bad_target_or_provider_before_any_read(self):
|
|
calls = []
|
|
def read_record(endpoint):
|
|
calls.append(endpoint)
|
|
self.fail('invalid collector request reached read callback')
|
|
def fetch_page(endpoint, page):
|
|
calls.append((endpoint, page))
|
|
self.fail('invalid collector request reached page callback')
|
|
for field, value in [('issue', True), ('owner', '../other'), ('repository', 'bad/name'),
|
|
('head', 'not-a-full-sha')]:
|
|
target = copy.deepcopy(fixture.result()['target']); target[field] = value
|
|
with self.subTest(field=field), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.mod.collect_publication_snapshot(read_record, fetch_page, target,
|
|
provider_contract=self.contract())
|
|
contract = self.contract(); contract['version']['version'] = 'other'
|
|
with self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.mod.collect_publication_snapshot(read_record, fetch_page, fixture.result()['target'],
|
|
provider_contract=contract)
|
|
self.assertEqual(calls, [])
|
|
|
|
def test_collector_legacy_shape_and_complete_source_page_validation(self):
|
|
before, after, review, result, paths = self.snapshots()
|
|
prefix = '/repos/acme/repo'
|
|
records = {prefix + '/issues/3': before['issue'], prefix + '/pulls/7': before['pr'],
|
|
prefix + '/issues/7': before['pr_issue']}
|
|
calls = []
|
|
def fetch_page(endpoint, page):
|
|
calls.append((endpoint, page))
|
|
return {'items': [], 'total_count': 0, 'has_more': False}
|
|
legacy = fixture.mod.collect_publication_snapshot(records.__getitem__, fetch_page, result['target'])
|
|
self.assertEqual(set(legacy), {'issue', 'pr', 'issue_comments', 'pr_comments', 'reviews', 'timeline', 'files'})
|
|
self.assertNotIn((prefix + '/issues/3/timeline', 1), calls)
|
|
for envelope in ({'items': [{'id': 1}, {'id': 1}], 'total_count': 2, 'has_more': False},
|
|
{'items': [], 'total_count': 1, 'has_more': True},
|
|
{'items': None, 'has_more': False}):
|
|
def bad_page(endpoint, page):
|
|
if endpoint == prefix + '/issues/3/timeline':
|
|
return envelope
|
|
return {'items': [], 'total_count': 0, 'has_more': False}
|
|
with self.subTest(envelope=envelope), self.assertRaises(fixture.mod.ValidationError):
|
|
fixture.mod.collect_publication_snapshot(records.__getitem__, bad_page, result['target'],
|
|
provider_contract=self.contract())
|
|
|
|
def test_collector_reads_source_timeline_and_pr_issue_through_readonly_callbacks(self):
|
|
before, after, review, result, paths = self.snapshots()
|
|
mod = fixture.mod
|
|
self.assertTrue(callable(getattr(mod, 'collect_publication_snapshot', None)),
|
|
'canonical read-only publication snapshot collector missing')
|
|
prefix = '/repos/acme/repo'
|
|
endpoints = {'issue': '/issues/3', 'pr': '/pulls/7', 'pr_issue': '/issues/7',
|
|
'issue_comments': '/issues/3/comments', 'pr_comments': '/issues/7/comments',
|
|
'reviews': '/pulls/7/reviews', 'timeline': '/issues/7/timeline',
|
|
'issue_timeline': '/issues/3/timeline', 'files': '/pulls/7/files'}
|
|
captured = []
|
|
for snapshot in (before, after):
|
|
records = {prefix + endpoints[k]: v for k, v in snapshot.items()}
|
|
calls = []
|
|
def read_record(endpoint):
|
|
calls.append(('GET', endpoint))
|
|
return copy.deepcopy(records[endpoint])
|
|
def fetch_page(endpoint, page):
|
|
calls.append(('GET-page', endpoint, page))
|
|
items = records[endpoint]
|
|
return {'items': copy.deepcopy(items), 'total_count': len(items), 'has_more': False}
|
|
out = mod.collect_publication_snapshot(read_record, fetch_page, result['target'],
|
|
provider_contract=self.contract())
|
|
self.assertEqual(out, snapshot)
|
|
self.assertIn(('GET', prefix + '/issues/7'), calls)
|
|
self.assertIn(('GET-page', prefix + '/issues/3/timeline', 1), calls)
|
|
self.assertTrue(all(call[0].startswith('GET') for call in calls))
|
|
captured.append(out)
|
|
self.assertEqual(fixture.verify_fixture(*captured, review, result, paths,
|
|
self.contract())['source_issue_reference_ids'], [102])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|