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()