frontend-patterns/tests/test_reference_preview.py

115 lines
4.2 KiB
Python
Raw Permalink Normal View History

"""Reject broken preview contracts before publishing generated references."""
import hashlib
import importlib.util
import json
from pathlib import Path
import shutil
import sys
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
spec = importlib.util.spec_from_file_location('reference_preview', ROOT / 'scripts/reference_preview.py')
preview = importlib.util.module_from_spec(spec)
spec.loader.exec_module(preview)
@pytest.fixture
def copy(tmp_path):
for name in ('research', 'canon', 'workplans', 'sequences', 'technologies', 'reference'):
shutil.copytree(ROOT / name, tmp_path / name)
shutil.copy(ROOT / 'START.md', tmp_path / 'START.md')
(tmp_path / '.git').symlink_to(ROOT / '.git', target_is_directory=True)
return tmp_path
def mutate(root, edit):
path = root / 'reference/0.1/manifest.json'
data = json.loads(path.read_text())
edit(data)
path.write_text(json.dumps(data))
def test_current_export():
for name, content in preview.build().items():
assert (ROOT / 'reference/0.1' / name).read_text() == content
@pytest.mark.parametrize('field,value', [('target', 'CAND-9999'), ('type', 'magically-guarantees'), ('condition', ''), ('evidence_refs', ['missing.md'])])
def test_bad_relation(copy, field, value):
mutate(copy, lambda m: m['relations'][0].update({field: value}))
with pytest.raises(ValueError):
preview.build(copy)
def test_duplicate_identity(copy):
mutate(copy, lambda m: m['core'].append(m['core'][0]))
with pytest.raises(ValueError):
preview.build(copy)
def test_live_candidate_change_does_not_break_frozen_preview(copy):
with (copy / 'research/candidates/ConflictCopy.md').open('a') as f:
f.write('\nChanged contract.\n')
assert preview.build(copy) == preview.build(ROOT)
import research_evolution as evolution
with pytest.raises(ValueError, match='Stale profile'):
evolution.validate(evolution.load(copy), copy)
def test_escaping_reference(copy):
mutate(copy, lambda m: m['entrypoints'].update(start='/etc/passwd'))
with pytest.raises(ValueError, match='Published manifest changed'):
preview.build(copy)
def test_missing_metadata(copy):
path = copy / 'reference/0.1/manifest.json'
data = json.loads(path.read_text())
del data['relations'][0]['rationale']
path.write_text(json.dumps(data))
with pytest.raises((ValueError, KeyError)):
preview.build(copy)
def test_legitimate_new_research_remains_separate_from_old_release(copy):
import research_evolution as evolution
candidate = copy / 'research/candidates/ConflictCopy.md'
candidate.write_text(candidate.read_text() + '\nNew research clarification.\n')
digest = hashlib.sha256(candidate.read_bytes()).hexdigest()
profiles = copy / 'research/evolution/profiles.json'
data = json.loads(profiles.read_text())
data['profiles'][0]['sha256'] = digest
profiles.write_text(json.dumps(data))
classes = copy / 'research/catalog/ClassificationCoverage.json'
data = json.loads(classes.read_text())
next(c for c in data['candidates'] if c['path'] == 'research/candidates/ConflictCopy.md')['sha256'] = digest
classes.write_text(json.dumps(data))
evolution.validate(evolution.load(copy), copy)
assert preview.build(copy, '0.1') == preview.build(ROOT, '0.1')
def test_unknown_version_and_forged_release_pin(copy):
with pytest.raises(ValueError, match='Unknown'):
preview.build(copy, '9.9')
path = copy / 'reference/releases.json'
data = json.loads(path.read_text())
data['releases']['0.1']['manifest_sha256'] = '0' * 64
path.write_text(json.dumps(data))
with pytest.raises(ValueError, match='digest mismatch'):
preview.build(copy)
def test_published_cases_cannot_be_rewritten(copy):
path = copy / 'reference/0.1/development-cases.json'
path.write_text('{}')
with pytest.raises(ValueError, match='Published artifact changed'):
preview.build(copy)
@pytest.mark.parametrize('path', ['/etc/passwd', '../README.md', 'research/../README.md', 'research//README.md'])
def test_git_blob_rejects_escaping_paths(path):
with pytest.raises(ValueError, match='Invalid reference'):
preview.git_blob(ROOT, '0' * 40, path)