Add durable portable graph publication
This commit is contained in:
parent
96e3965855
commit
1134c2d375
19 changed files with 2542 additions and 13 deletions
284
tests/test_graph_rendering.py
Normal file
284
tests/test_graph_rendering.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from unittest import mock
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.graph_projection import (
|
||||
GraphViewRequestV1,
|
||||
build_graph_projection_package,
|
||||
build_graph_view_plan,
|
||||
)
|
||||
from docforge.project import Project
|
||||
from docforge.projection_contract import ProjectionPackageV1
|
||||
from docforge_renderers.graph import PortableGraphHtmlRenderer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class PortableGraphRenderingTests(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()
|
||||
self.request = GraphViewRequestV1(
|
||||
view_id="architecture",
|
||||
title="Alpha architecture",
|
||||
root_node_id="guide.workflow",
|
||||
depth=2,
|
||||
max_nodes=20,
|
||||
max_edges=40,
|
||||
max_work=1_000,
|
||||
)
|
||||
self.plan = build_graph_view_plan(self.snapshot, self.request, False)
|
||||
self.package = build_graph_projection_package(
|
||||
self.plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
|
||||
def test_portable_artifact_is_deterministic_self_contained_and_generation_bound(self) -> None:
|
||||
renderer = PortableGraphHtmlRenderer()
|
||||
first = renderer.render(self.package)
|
||||
second = renderer.render(self.package)
|
||||
self.assertEqual(first.artifacts, second.artifacts)
|
||||
self.assertEqual(1, len(first.artifacts))
|
||||
artifact = first.artifacts[0]
|
||||
self.assertEqual("portable-graph.html", artifact.artifact_id)
|
||||
self.assertEqual("text/html; charset=utf-8", artifact.media_type)
|
||||
receipt = first.receipt.as_dict()
|
||||
evidence = cast(list[dict[str, object]], receipt["artifacts"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(artifact.content).hexdigest(),
|
||||
evidence[0]["sha256"],
|
||||
)
|
||||
rendered = artifact.content.decode("utf-8")
|
||||
self.assertIn("Content-Security-Policy", rendered)
|
||||
self.assertIn("default-src 'none'", rendered)
|
||||
self.assertIn('type="application/json"', rendered)
|
||||
self.assertNotIn(str(self.root), rendered)
|
||||
self.assertNotIn("docs/content/", rendered)
|
||||
self.assertNotIn("Editors change canonical nodes", rendered)
|
||||
self.assertNotIn("fetch(", rendered)
|
||||
self.assertNotIn("XMLHttpRequest", rendered)
|
||||
self.assertNotIn("WebSocket", rendered)
|
||||
self.assertIn(self.snapshot.source_hash, rendered)
|
||||
|
||||
def test_embedded_plan_is_exact_and_script_breakout_is_inert(self) -> None:
|
||||
plan = copy.deepcopy(self.plan.as_dict())
|
||||
view = plan["view"]
|
||||
assert isinstance(view, dict)
|
||||
view["title"] = '</script><script id="injected">alert(1)</script>'
|
||||
plan.pop("plan_id")
|
||||
from docforge.projection_contract import GraphViewPlanV1
|
||||
|
||||
malicious = GraphViewPlanV1.create(plan)
|
||||
package = build_graph_projection_package(
|
||||
malicious,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
rendered = PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
|
||||
self.assertNotIn('<script id="injected">', rendered)
|
||||
self.assertIn("</script>", rendered)
|
||||
prefix = '<script id="docforge-graph-plan" type="application/json">'
|
||||
embedded = rendered.split(prefix, 1)[1].split("</script>", 1)[0]
|
||||
self.assertEqual(malicious.as_dict(), json.loads(embedded))
|
||||
|
||||
def test_renderer_has_no_project_database_or_filesystem_write_capability(self) -> None:
|
||||
forbidden = AssertionError("portable graph 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(os, "replace", side_effect=forbidden),
|
||||
mock.patch.object(os, "rename", side_effect=forbidden),
|
||||
mock.patch.object(os, "unlink", side_effect=forbidden),
|
||||
):
|
||||
result = PortableGraphHtmlRenderer().render(self.package)
|
||||
self.assertEqual(1, len(result.artifacts))
|
||||
|
||||
def test_renderer_rejects_wrong_kind_identity_assets_and_size(self) -> None:
|
||||
wrong = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": "other",
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["portable-graph.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as unsupported:
|
||||
PortableGraphHtmlRenderer().render(wrong)
|
||||
self.assertEqual("unsupported_renderer", unsupported.exception.code)
|
||||
|
||||
with_asset = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=[],
|
||||
assets=[
|
||||
{
|
||||
"asset_id": "project-script",
|
||||
"media_type": "text/javascript",
|
||||
"sha256": "0" * 64,
|
||||
"text": "alert(1)",
|
||||
}
|
||||
],
|
||||
output_policy={"artifact_ids": ["portable-graph.html"], "max_total_bytes": 1_000_000},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as assets:
|
||||
PortableGraphHtmlRenderer().render(with_asset)
|
||||
self.assertEqual("invalid_projection", assets.exception.code)
|
||||
|
||||
wrong_components = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=[],
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["portable-graph.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as components:
|
||||
PortableGraphHtmlRenderer().render(wrong_components)
|
||||
self.assertEqual("invalid_projection", components.exception.code)
|
||||
|
||||
wrong_artifact = ProjectionPackageV1.create(
|
||||
kind="graph",
|
||||
plan=self.plan,
|
||||
renderer={
|
||||
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
|
||||
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
|
||||
},
|
||||
components=self.package.document["components"], # type: ignore[arg-type]
|
||||
assets=[],
|
||||
output_policy={
|
||||
"artifact_ids": ["unexpected.html"],
|
||||
"max_total_bytes": 1_000_000,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as artifact:
|
||||
PortableGraphHtmlRenderer().render(wrong_artifact)
|
||||
self.assertEqual("invalid_projection", artifact.exception.code)
|
||||
|
||||
tiny = build_graph_projection_package(
|
||||
self.plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as too_large:
|
||||
PortableGraphHtmlRenderer().render(tiny)
|
||||
self.assertEqual("render_too_large", too_large.exception.code)
|
||||
|
||||
def test_initial_mode_is_honored_and_logic_is_rejected(self) -> None:
|
||||
for mode in ("nodes", "flow", "web"):
|
||||
with self.subTest(mode=mode):
|
||||
plan = build_graph_view_plan(
|
||||
self.snapshot,
|
||||
GraphViewRequestV1(
|
||||
view_id=f"{mode}-view",
|
||||
title=f"{mode.title()} view",
|
||||
root_node_id="guide.workflow",
|
||||
initial_mode=mode, # type: ignore[arg-type]
|
||||
depth=2,
|
||||
max_nodes=20,
|
||||
max_edges=40,
|
||||
max_work=1_000,
|
||||
),
|
||||
False,
|
||||
)
|
||||
package = build_graph_projection_package(
|
||||
plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
rendered = (
|
||||
PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
|
||||
)
|
||||
self.assertIn(f'data-mode="{mode}"', rendered)
|
||||
self.assertIn(f'<option value="{mode}" selected>', rendered)
|
||||
|
||||
logic_plan = build_graph_view_plan(
|
||||
self.snapshot,
|
||||
GraphViewRequestV1(
|
||||
view_id="logic-view",
|
||||
title="Logic view",
|
||||
root_node_id="guide.workflow",
|
||||
initial_mode="logic",
|
||||
include_logic=True,
|
||||
),
|
||||
True,
|
||||
)
|
||||
logic_package = build_graph_projection_package(
|
||||
logic_plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=1_000_000,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as logic:
|
||||
PortableGraphHtmlRenderer().render(logic_package)
|
||||
self.assertEqual("unsupported_renderer", logic.exception.code)
|
||||
|
||||
def test_artifact_exposes_accessible_static_and_interactive_structure(self) -> None:
|
||||
rendered = (
|
||||
PortableGraphHtmlRenderer().render(self.package).artifacts[0].content.decode("utf-8")
|
||||
)
|
||||
static = rendered.split('<script id="docforge-graph-plan"', 1)[0]
|
||||
self.assertIn("Editing workflow (guide.workflow)", static)
|
||||
self.assertIn("<td>guide.workflow</td>", static)
|
||||
self.assertIn('data-panel="nodes"', static)
|
||||
self.assertIn('data-panel="relationships"', static)
|
||||
for expected in (
|
||||
'href="#main"',
|
||||
'<main id="main" tabindex="-1" data-mode="nodes">',
|
||||
'role="group" aria-label="Graph controls"',
|
||||
'role="status" aria-live="polite"',
|
||||
'<th scope="col">Source</th>',
|
||||
'<th scope="col">Relation</th>',
|
||||
'<th scope="col">Target</th>',
|
||||
'<dialog id="node-dialog"',
|
||||
'aria-labelledby="node-dialog-title"',
|
||||
'dialog.addEventListener("close", () => opener?.focus())',
|
||||
"@media (prefers-reduced-motion: reduce)",
|
||||
"@media (forced-colors: active)",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue