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'