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"] = '' 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('", 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'