Add versioned independent projection contracts
This commit is contained in:
parent
4c5773c865
commit
96e3965855
22 changed files with 3561 additions and 133 deletions
486
tests/test_projection_contract.py
Normal file
486
tests/test_projection_contract.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.manual_projection import (
|
||||
build_manual_projection_package,
|
||||
build_manual_render_plan,
|
||||
)
|
||||
from docforge.models import Edge
|
||||
from docforge.project import Project
|
||||
from docforge.projection_contract import (
|
||||
MANUAL_RENDER_PLAN_CONTRACT,
|
||||
PROJECTION_PACKAGE_CONTRACT,
|
||||
PROJECTION_RECEIPT_CONTRACT,
|
||||
ManualRenderPlanV1,
|
||||
ProjectionArtifact,
|
||||
ProjectionPackageV1,
|
||||
ProjectionReceiptV1,
|
||||
canonical_projection_bytes,
|
||||
projection_hash,
|
||||
)
|
||||
from docforge.render_contract import GenericHtmlRenderer
|
||||
from docforge_renderers.manual import ManualHtmlRenderer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
ALPHA_RENDERER_VERSION = "1+markdown-it-py-4.2.0"
|
||||
ALPHA_RENDER_IDENTITY = "1c0a49c28ba3b0dabf94be36e75def197dee1be3cb73ac405b09875383c8dc5f"
|
||||
ALPHA_OUTPUT_HASH = "81656bb89debc7ad1fbe8bc290e9a3ba90664442b17a6d57e908d30d20c47f77"
|
||||
ALPHA_OUTPUT_BYTES = 2043
|
||||
|
||||
|
||||
class ProjectionContractTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name) / "alpha"
|
||||
shutil.copytree(FIXTURES / "alpha", self.root)
|
||||
self.project = Project.open(self.root)
|
||||
self.snapshot = self.project.load()
|
||||
assert self.snapshot.descriptor.render is not None
|
||||
self.view = self.snapshot.descriptor.render.views[0]
|
||||
self.template = self.view.template_path.read_bytes()
|
||||
self.plan = build_manual_render_plan(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.package = build_manual_projection_package(
|
||||
self.plan,
|
||||
self.template,
|
||||
renderer_id="generic_html",
|
||||
renderer_version=ALPHA_RENDERER_VERSION,
|
||||
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
|
||||
)
|
||||
|
||||
def test_canonical_identity_is_stable_and_tampering_is_rejected(self) -> None:
|
||||
self.assertEqual(
|
||||
b'{"a":"\xc3\xa9","b":1}',
|
||||
canonical_projection_bytes({"b": 1, "a": "\N{LATIN SMALL LETTER E WITH ACUTE}"}),
|
||||
)
|
||||
self.assertEqual(
|
||||
projection_hash(
|
||||
{key: value for key, value in self.plan.as_dict().items() if key != "plan_id"}
|
||||
),
|
||||
self.plan.plan_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.plan.plan_id,
|
||||
build_manual_render_plan(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
changeset_hash=None,
|
||||
).plan_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.package.package_id,
|
||||
ProjectionPackageV1.from_dict(
|
||||
json.loads(json.dumps(self.package.as_dict()))
|
||||
).package_id,
|
||||
)
|
||||
|
||||
tampered_plan = copy.deepcopy(self.plan.as_dict())
|
||||
pages = tampered_plan["pages"]
|
||||
assert isinstance(pages, list)
|
||||
assert isinstance(pages[0], dict)
|
||||
pages[0]["title"] = "Tampered title"
|
||||
with self.assertRaises(DocForgeError) as plan_error:
|
||||
ManualRenderPlanV1.from_dict(tampered_plan)
|
||||
self.assertEqual("invalid_projection", plan_error.exception.code)
|
||||
|
||||
tampered_package = copy.deepcopy(self.package.as_dict())
|
||||
assets = tampered_package["assets"]
|
||||
assert isinstance(assets, list)
|
||||
assert isinstance(assets[0], dict)
|
||||
assets[0]["text"] = f"{assets[0]['text']}\nTampered"
|
||||
with self.assertRaises(DocForgeError) as package_error:
|
||||
ProjectionPackageV1.from_dict(tampered_package)
|
||||
self.assertEqual("invalid_projection", package_error.exception.code)
|
||||
|
||||
def test_contract_documents_reject_unknown_or_malformed_fields(self) -> None:
|
||||
plan_with_extra = copy.deepcopy(self.plan.as_dict())
|
||||
plan_with_extra["unexpected"] = True
|
||||
with self.assertRaises(DocForgeError) as extra_plan:
|
||||
ManualRenderPlanV1.from_dict(plan_with_extra)
|
||||
self.assertEqual("invalid_projection", extra_plan.exception.code)
|
||||
|
||||
plan_with_foreign_project = copy.deepcopy(self.plan.as_dict())
|
||||
project = plan_with_foreign_project["project"]
|
||||
assert isinstance(project, dict)
|
||||
project["absolute_root"] = str(self.root)
|
||||
with self.assertRaises(DocForgeError) as foreign_project:
|
||||
ManualRenderPlanV1.from_dict(plan_with_foreign_project)
|
||||
self.assertEqual("invalid_projection", foreign_project.exception.code)
|
||||
|
||||
package_with_extra = copy.deepcopy(self.package.as_dict())
|
||||
package_with_extra["unexpected"] = []
|
||||
with self.assertRaises(DocForgeError) as extra_package:
|
||||
ProjectionPackageV1.from_dict(package_with_extra)
|
||||
self.assertEqual("invalid_projection", extra_package.exception.code)
|
||||
|
||||
result = ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(self.package)
|
||||
receipt_with_extra = copy.deepcopy(result.receipt.as_dict())
|
||||
receipt_with_extra["artifact_bytes"] = "forbidden"
|
||||
with self.assertRaises(DocForgeError) as extra_receipt:
|
||||
ProjectionReceiptV1.from_dict(receipt_with_extra)
|
||||
self.assertEqual("invalid_projection", extra_receipt.exception.code)
|
||||
|
||||
with self.assertRaises(DocForgeError) as path_artifact:
|
||||
ProjectionReceiptV1.create(
|
||||
kind="manual",
|
||||
package_id=self.package.package_id,
|
||||
plan_id=self.plan.plan_id,
|
||||
renderer={
|
||||
"renderer_id": "generic_html",
|
||||
"renderer_version": ALPHA_RENDERER_VERSION,
|
||||
},
|
||||
artifacts=[
|
||||
{
|
||||
"artifact_id": "../manual.html",
|
||||
"media_type": "text/html",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 1,
|
||||
}
|
||||
],
|
||||
diagnostics={},
|
||||
timing={"elapsed_ns": 0},
|
||||
peak_memory_bytes=None,
|
||||
)
|
||||
self.assertEqual("invalid_projection", path_artifact.exception.code)
|
||||
|
||||
def test_projection_package_is_path_free_and_rejects_runtime_references(self) -> None:
|
||||
serialized = canonical_projection_bytes(self.package.as_dict())
|
||||
self.assertNotIn(str(self.root).encode("utf-8"), serialized)
|
||||
self.assertNotIn(b"source_path", serialized)
|
||||
self.assertNotIn(b"sqlite", serialized.lower())
|
||||
|
||||
with self.assertRaises(DocForgeError) as absolute_path:
|
||||
ProjectionPackageV1.create(
|
||||
kind="manual",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": "generic_html",
|
||||
"renderer_version": ALPHA_RENDERER_VERSION,
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["manual.html"],
|
||||
"max_total_bytes": 1000,
|
||||
"template_path": "/home/example/private-template.html",
|
||||
},
|
||||
)
|
||||
self.assertEqual("invalid_projection", absolute_path.exception.code)
|
||||
|
||||
with self.assertRaises(DocForgeError) as database_reference:
|
||||
ProjectionPackageV1.create(
|
||||
kind="manual",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": "generic_html",
|
||||
"renderer_version": ALPHA_RENDERER_VERSION,
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["manual.html"],
|
||||
"max_total_bytes": 1000,
|
||||
"database": "index.sqlite3",
|
||||
},
|
||||
)
|
||||
self.assertEqual("invalid_projection", database_reference.exception.code)
|
||||
|
||||
def test_receipt_attests_artifacts_without_embedding_content(self) -> None:
|
||||
result = ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(
|
||||
self.package,
|
||||
render_identity=ALPHA_RENDER_IDENTITY,
|
||||
)
|
||||
self.assertEqual(1, len(result.artifacts))
|
||||
artifact = result.artifacts[0]
|
||||
evidence = artifact.evidence()
|
||||
receipt = result.receipt.as_dict()
|
||||
|
||||
self.assertEqual(self.package.package_id, receipt["package_id"])
|
||||
self.assertEqual(self.plan.plan_id, receipt["plan_id"])
|
||||
self.assertEqual([evidence], receipt["artifacts"])
|
||||
self.assertEqual(PROJECTION_RECEIPT_CONTRACT, receipt["contract"])
|
||||
self.assertEqual(
|
||||
{
|
||||
"renderer_id": "generic_html",
|
||||
"renderer_version": ALPHA_RENDERER_VERSION,
|
||||
},
|
||||
receipt["renderer"],
|
||||
)
|
||||
self.assertEqual({"warnings": []}, receipt["diagnostics"])
|
||||
self.assertIsNone(receipt["peak_memory_bytes"])
|
||||
timing = receipt["timing"]
|
||||
assert isinstance(timing, dict)
|
||||
self.assertGreaterEqual(timing["elapsed_ns"], 0)
|
||||
self.assertNotIn("content", evidence)
|
||||
self.assertNotIn(artifact.content, canonical_projection_bytes(receipt))
|
||||
self.assertEqual(
|
||||
receipt["receipt_id"],
|
||||
ProjectionReceiptV1.from_dict(copy.deepcopy(receipt)).receipt_id,
|
||||
)
|
||||
|
||||
tampered_receipt = copy.deepcopy(receipt)
|
||||
artifacts = tampered_receipt["artifacts"]
|
||||
assert isinstance(artifacts, list)
|
||||
assert isinstance(artifacts[0], dict)
|
||||
artifacts[0]["bytes"] = int(artifacts[0]["bytes"]) + 1
|
||||
with self.assertRaises(DocForgeError) as tampered:
|
||||
ProjectionReceiptV1.from_dict(tampered_receipt)
|
||||
self.assertEqual("invalid_projection", tampered.exception.code)
|
||||
|
||||
def test_manual_plan_is_deterministic_and_preserves_alpha_semantics(self) -> None:
|
||||
document = self.plan.as_dict()
|
||||
self.assertEqual(MANUAL_RENDER_PLAN_CONTRACT, document["contract"])
|
||||
self.assertIsNone(document["changeset_hash"])
|
||||
pages = document["pages"]
|
||||
navigation = document["navigation"]
|
||||
search_documents = document["search_documents"]
|
||||
diagnostics = document["diagnostics"]
|
||||
assert isinstance(pages, list)
|
||||
assert isinstance(navigation, list)
|
||||
assert isinstance(search_documents, list)
|
||||
assert isinstance(diagnostics, dict)
|
||||
|
||||
self.assertEqual(
|
||||
["guide.foundation", "guide.workflow", "proof.validation"],
|
||||
[page["node_id"] for page in pages],
|
||||
)
|
||||
self.assertEqual(
|
||||
["guide.foundation", "guide.workflow", "proof.validation"],
|
||||
[item["node_id"] for item in navigation],
|
||||
)
|
||||
self.assertEqual(
|
||||
["guide.foundation", "guide.workflow", "proof.validation"],
|
||||
[item["node_id"] for item in search_documents],
|
||||
)
|
||||
self.assertEqual([], diagnostics["orphans"])
|
||||
self.assertEqual([], diagnostics["cycles"])
|
||||
|
||||
page_by_id = {page["node_id"]: page for page in pages}
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "guide.workflow",
|
||||
"relation": "depends_on",
|
||||
"target_id": "guide.foundation",
|
||||
}
|
||||
],
|
||||
page_by_id["guide.foundation"]["backlinks"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "guide.workflow",
|
||||
"relation": "depends_on",
|
||||
"target_id": "guide.foundation",
|
||||
}
|
||||
],
|
||||
page_by_id["guide.workflow"]["cross_references"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "proof.validation",
|
||||
"relation": "proves",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
page_by_id["guide.workflow"]["backlinks"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "proof.validation",
|
||||
"relation": "proves",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
page_by_id["proof.validation"]["cross_references"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
page["components"]
|
||||
== [
|
||||
"manual.node-metadata@1",
|
||||
"manual.summary@1",
|
||||
"manual.commonmark@1",
|
||||
"manual.relationships@1",
|
||||
]
|
||||
for page in pages
|
||||
)
|
||||
)
|
||||
|
||||
proposed = build_manual_render_plan(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
changeset_hash="a" * 64,
|
||||
)
|
||||
self.assertNotEqual(self.plan.plan_id, proposed.plan_id)
|
||||
self.assertEqual("a" * 64, proposed.as_dict()["changeset_hash"])
|
||||
|
||||
def test_cycle_orphan_backlink_and_cross_reference_planning(self) -> None:
|
||||
edges = (
|
||||
Edge("guide.foundation", "relates_to", "guide.workflow"),
|
||||
Edge("guide.workflow", "returns_to", "guide.foundation"),
|
||||
)
|
||||
snapshot = replace(self.snapshot, edges=edges)
|
||||
first = build_manual_render_plan(snapshot, self.view, changeset_hash=None)
|
||||
second = build_manual_render_plan(snapshot, self.view, changeset_hash=None)
|
||||
self.assertEqual(first.plan_id, second.plan_id)
|
||||
|
||||
document = first.as_dict()
|
||||
diagnostics = document["diagnostics"]
|
||||
pages = document["pages"]
|
||||
assert isinstance(diagnostics, dict)
|
||||
assert isinstance(pages, list)
|
||||
self.assertEqual(["proof.validation"], diagnostics["orphans"])
|
||||
self.assertEqual(
|
||||
[["guide.foundation", "guide.workflow"]],
|
||||
diagnostics["cycles"],
|
||||
)
|
||||
|
||||
page_by_id = {page["node_id"]: page for page in pages}
|
||||
foundation = page_by_id["guide.foundation"]
|
||||
workflow = page_by_id["guide.workflow"]
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "guide.foundation",
|
||||
"relation": "relates_to",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
foundation["cross_references"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"source_id": "guide.workflow",
|
||||
"relation": "returns_to",
|
||||
"target_id": "guide.foundation",
|
||||
}
|
||||
],
|
||||
foundation["backlinks"],
|
||||
)
|
||||
self.assertEqual(
|
||||
foundation["cross_references"],
|
||||
workflow["backlinks"],
|
||||
)
|
||||
self.assertEqual(
|
||||
foundation["backlinks"],
|
||||
workflow["cross_references"],
|
||||
)
|
||||
|
||||
def test_alpha_compatibility_shim_preserves_legacy_identity_and_bytes(self) -> None:
|
||||
renderer = GenericHtmlRenderer()
|
||||
self.assertEqual(ALPHA_RENDERER_VERSION, renderer.renderer_version)
|
||||
prepared = renderer.prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
|
||||
self.assertEqual(ALPHA_RENDER_IDENTITY, prepared.render_identity)
|
||||
self.assertEqual(ALPHA_OUTPUT_HASH, prepared.output_hash)
|
||||
self.assertEqual(ALPHA_OUTPUT_BYTES, len(prepared.output))
|
||||
self.assertEqual(
|
||||
ALPHA_OUTPUT_HASH,
|
||||
hashlib.sha256(prepared.output).hexdigest(),
|
||||
)
|
||||
self.assertEqual(b"<!DOCTYPE html>", prepared.output.splitlines()[0])
|
||||
self.assertTrue(prepared.output.endswith(b"</html>\n"))
|
||||
self.assertIn(
|
||||
f'content="{ALPHA_RENDER_IDENTITY}"'.encode(),
|
||||
prepared.output,
|
||||
)
|
||||
|
||||
def test_manual_renderer_rejects_project_provided_active_content(self) -> None:
|
||||
for active in (
|
||||
"<script>alert(1)</script>{{ docforge_content }}",
|
||||
'<main onclick="alert(1)">{{ docforge_content }}</main>',
|
||||
'<a href="javascript:alert(1)">{{ docforge_content }}</a>',
|
||||
'<iframe src="https://example.invalid"></iframe>{{ docforge_content }}',
|
||||
'<meta http-equiv="refresh" content="0">{{ docforge_content }}',
|
||||
):
|
||||
with self.subTest(active=active):
|
||||
package = build_manual_projection_package(
|
||||
self.plan,
|
||||
active.encode("utf-8"),
|
||||
renderer_id="generic_html",
|
||||
renderer_version=ALPHA_RENDERER_VERSION,
|
||||
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as rejected:
|
||||
ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(package)
|
||||
self.assertEqual("invalid_template", rejected.exception.code)
|
||||
|
||||
def test_manual_renderer_has_no_project_sqlite_or_path_write_capability(self) -> None:
|
||||
renderer = ManualHtmlRenderer(ALPHA_RENDERER_VERSION)
|
||||
forbidden = AssertionError("manual renderer crossed its capability boundary")
|
||||
with (
|
||||
mock.patch.object(Project, "open", side_effect=forbidden),
|
||||
mock.patch.object(Project, "load", side_effect=forbidden),
|
||||
mock.patch.object(sqlite3, "connect", side_effect=forbidden),
|
||||
mock.patch.object(Path, "write_bytes", side_effect=forbidden),
|
||||
mock.patch.object(Path, "write_text", side_effect=forbidden),
|
||||
mock.patch.object(Path, "mkdir", side_effect=forbidden),
|
||||
mock.patch.object(Path, "touch", side_effect=forbidden),
|
||||
mock.patch.object(Path, "unlink", side_effect=forbidden),
|
||||
mock.patch.object(Path, "rename", side_effect=forbidden),
|
||||
mock.patch.object(Path, "replace", side_effect=forbidden),
|
||||
mock.patch.object(os, "mkdir", side_effect=forbidden),
|
||||
mock.patch.object(os, "makedirs", side_effect=forbidden),
|
||||
mock.patch.object(os, "rename", side_effect=forbidden),
|
||||
mock.patch.object(os, "replace", side_effect=forbidden),
|
||||
mock.patch.object(os, "unlink", side_effect=forbidden),
|
||||
):
|
||||
result = renderer.render(
|
||||
self.package,
|
||||
render_identity=ALPHA_RENDER_IDENTITY,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(result.artifacts))
|
||||
self.assertEqual("manual.html", result.artifacts[0].artifact_id)
|
||||
self.assertEqual(ALPHA_OUTPUT_HASH, result.artifacts[0].evidence()["sha256"])
|
||||
|
||||
def test_projection_artifact_evidence_is_canonical_and_content_free(self) -> None:
|
||||
artifact = ProjectionArtifact(
|
||||
artifact_id="manual.html",
|
||||
media_type="text/html; charset=utf-8",
|
||||
content=b"manual bytes",
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"artifact_id": "manual.html",
|
||||
"media_type": "text/html; charset=utf-8",
|
||||
"sha256": hashlib.sha256(b"manual bytes").hexdigest(),
|
||||
"bytes": len(b"manual bytes"),
|
||||
},
|
||||
artifact.evidence(),
|
||||
)
|
||||
self.assertEqual(
|
||||
PROJECTION_PACKAGE_CONTRACT,
|
||||
self.package.as_dict()["contract"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue