284 lines
12 KiB
Python
284 lines
12 KiB
Python
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()
|