policy-nexus/tests/test_published_history.py
tegwick 65862b1e0c
All checks were successful
Build and publish policy-nexus image / build-and-push (push) Successful in 1m25s
Reject history overwrite and unreviewed publication routing changes
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 02:31:41 +02:00

89 lines
5.5 KiB
Python

from pathlib import Path
import hashlib
import json
import tempfile
import unittest
from unittest import mock
from test_publication import _fixture, build_site
class PublishedHistoryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.manifest = _fixture(self.root)
self.source = self.root/'canon-repo/canon/standards/example.md'
self.output = self.root/'build'
self.env = mock.patch.dict('os.environ', {'POLICY_NEXUS_SOURCE_ROOT':''})
self.env.start();self.addCleanup(self.env.stop)
with mock.patch.object(build_site, '_source_revision', return_value='a'*40):
build_site.build(self.manifest,self.output)
self.delivered = json.loads((self.output/'publication-manifest.json').read_text())
self.doc = self.delivered['documents'][0]
self.original = (self.output/self.doc['revision_path']).read_bytes()
self.archive = self.root/'archive';self.archive.mkdir()
(self.archive/'source.md').write_bytes(self.source.read_bytes())
(self.archive/'page.html').write_bytes(self.original)
delivered_bytes = (self.output/'publication-manifest.json').read_bytes()
(self.archive/'manifest.json').write_bytes(delivered_bytes)
record = {k:self.doc[k] for k in ['id','revision','revision_path','source_repo','source_path','source_revision','source_digest']}
record.update(archived_source_path='source.md',rendered_page_path='page.html',rendered_page_digest=hashlib.sha256(self.original).hexdigest())
self.receipt = {'schema_version':'policy-nexus-published-history/v1','publication_manifest_path':'manifest.json','publication_manifest_digest':hashlib.sha256(delivered_bytes).hexdigest(),'documents':[record]}
self.configure()
def configure(self):
data = json.dumps(self.receipt).encode();(self.archive/'receipt.json').write_bytes(data)
manifest = json.loads(self.manifest.read_text());manifest['published_history']=[{'receipt_path':'archive/receipt.json','receipt_digest':hashlib.sha256(data).hexdigest()}]
self.manifest.write_text(json.dumps(manifest))
def clean_build(self):
with mock.patch.object(build_site, '_source_revision', return_value='b'*40):
return build_site.build(self.manifest,self.root/'clean')
def test_clean_new_head_same_source_retains_actual_old_bytes(self):
self.clean_build()
self.assertEqual((self.root/'clean'/self.doc['revision_path']).read_bytes(),self.original)
self.assertIn(b'b'*40,(self.root/'clean'/self.doc['canonical_path']).read_bytes())
def test_new_revision_retains_previous_path_in_clean_build(self):
self.source.write_text(self.source.read_text().replace('draft-1','draft-2').replace('No position.','Updated position.'))
records=self.clean_build()
self.assertEqual((self.root/'clean'/self.doc['revision_path']).read_bytes(),self.original)
self.assertTrue((self.root/'clean'/records[0]['revision_path']).is_file())
def test_conflicting_existing_history_refuses_without_replacing_output(self):
path=self.output/self.doc['revision_path'];path.write_bytes(b'conflict')
with self.assertRaisesRegex(RuntimeError,'conflicts'):
build_site.build(self.manifest,self.output)
self.assertEqual(path.read_bytes(),b'conflict')
def test_missing_archived_old_page_refuses_clean_build(self):
(self.archive/'page.html').unlink()
with self.assertRaisesRegex(ValueError,'artifact'):self.clean_build()
self.assertFalse((self.root/'clean').exists())
def test_tampered_original_owner_source_refuses(self):
(self.archive/'source.md').write_bytes(b'changed')
with self.assertRaisesRegex(RuntimeError,'owner source digest'):self.clean_build()
def test_delivered_manifest_coverage_cannot_drop_document(self):
self.receipt['documents']=[];self.configure()
with self.assertRaisesRegex(ValueError,'every delivered'):self.clean_build()
def test_changed_source_under_same_revision_refuses(self):
self.source.write_text(self.source.read_text().replace('No position.','Changed position.'))
with self.assertRaisesRegex(RuntimeError,'Publish a new revision'):self.clean_build()
def test_canonical_cannot_overwrite_archived_revision(self):
manifest=json.loads(self.manifest.read_text())
manifest['documents'][0]['canonical_path']=self.doc['revision_path']
self.manifest.write_text(json.dumps(manifest))
# Bump current revision so the ordinary manifest path check cannot catch it.
self.source.write_text(self.source.read_text().replace('draft-1','draft-2'))
with self.assertRaisesRegex(ValueError,'archived immutable'):
self.clean_build()
def test_legacy_redirect_cannot_overwrite_archived_revision(self):
manifest=json.loads(self.manifest.read_text())
manifest['documents'][0]['legacy_paths']=[self.doc['revision_path']]
self.manifest.write_text(json.dumps(manifest))
self.source.write_text(self.source.read_text().replace('draft-1','draft-2'))
with self.assertRaisesRegex(ValueError,'archived immutable'):
self.clean_build()
def test_delivered_commit_field_must_match_captured_manifest(self):
self.receipt['documents'][0]['delivered_manifest_source_revision']='c'*40
self.configure()
with self.assertRaisesRegex(RuntimeError,'delivered source revision'):
self.clean_build()