fischer-agentkit/tests/documents/test_template_renderer.py

147 lines
5.5 KiB
Python

"""Tests for TemplateRenderer — Word template filling with Jinja2 sandbox (U5)."""
from __future__ import annotations
from pathlib import Path
import pytest
from docx import Document
from agentkit.documents.renderers.template_renderer import TemplateRenderer
def _make_template(tmp_path: Path, content: str) -> Path:
"""Create a .docx template with the given text content (single paragraph)."""
template_path = tmp_path / "template.docx"
doc = Document()
doc.add_paragraph(content)
doc.save(str(template_path))
return template_path
def _read_text(path: Path) -> str:
"""Read all paragraph text from a .docx file."""
doc = Document(str(path))
return "\n".join(p.text for p in doc.paragraphs)
def test_simple_variable_substitution(tmp_path: Path) -> None:
"""{{name}} is replaced with data['name']."""
template = _make_template(tmp_path, "Hello, {{name}}!")
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template, {"name": "张三"}, output)
assert _read_text(output) == "Hello, 张三!"
def test_multiple_variables(tmp_path: Path) -> None:
"""Multiple {{var}} placeholders are all filled."""
template = _make_template(tmp_path, "{{greeting}}, {{name}}. You are {{role}}.")
output = tmp_path / "output.docx"
TemplateRenderer().render_template(
template, {"greeting": "Hi", "name": "Alice", "role": "admin"}, output
)
assert _read_text(output) == "Hi, Alice. You are admin."
def test_for_loop(tmp_path: Path) -> None:
"""{% for %} loop expands correctly."""
# Create a template with a for loop in a single paragraph
template_path = tmp_path / "template.docx"
doc = Document()
# docxtpl requires the for loop tags in the paragraph
doc.add_paragraph("{% for item in items %}{{item}} {% endfor %}")
doc.save(str(template_path))
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template_path, {"items": ["A", "B", "C"]}, output)
text = _read_text(output)
assert "A" in text
assert "B" in text
assert "C" in text
def test_if_condition(tmp_path: Path) -> None:
"""{% if %} conditional renders content when condition is true."""
template_path = tmp_path / "template.docx"
doc = Document()
doc.add_paragraph("{% if show %}Visible{% endif %}")
doc.save(str(template_path))
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template_path, {"show": True}, output)
assert "Visible" in _read_text(output)
def test_if_condition_false(tmp_path: Path) -> None:
"""{% if %} conditional hides content when condition is false."""
template_path = tmp_path / "template.docx"
doc = Document()
doc.add_paragraph("{% if show %}Visible{% endif %}")
doc.save(str(template_path))
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template_path, {"show": False}, output)
assert "Visible" not in _read_text(output)
def test_template_not_found(tmp_path: Path) -> None:
"""Missing template file raises FileNotFoundError."""
output = tmp_path / "output.docx"
with pytest.raises(FileNotFoundError, match="Template not found"):
TemplateRenderer().render_template(
tmp_path / "nonexistent.docx", {}, output
)
def test_no_placeholders(tmp_path: Path) -> None:
"""Template with no Jinja2 tags is output unchanged."""
template = _make_template(tmp_path, "Just plain text, no variables.")
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template, {}, output)
assert _read_text(output) == "Just plain text, no variables."
def test_ssti_blocked(tmp_path: Path) -> None:
"""Sandbox blocks access to dunder attributes (SSTI protection).
{{config.__class__}} should not expose Python internals. Jinja2's
SandboxedEnvironment returns Undefined for attributes starting with
'_', so the output is empty rather than raising — the key security
property is that internal class info is never leaked.
"""
template = _make_template(tmp_path, "{{config.__class__}}")
output = tmp_path / "output.docx"
# Should not raise (SandboxedEnvironment returns Undefined), but
# critically should NOT expose class info.
TemplateRenderer().render_template(template, {"config": {}}, output)
text = _read_text(output)
# The dunder access is blocked — no class info leaks
assert "dict" not in text.lower()
assert "class" not in text.lower()
assert "{{" not in text # placeholder is consumed (replaced with empty)
def test_ssti_globals_blocked(tmp_path: Path) -> None:
"""Sandbox blocks __globals__ access (deeper SSTI payload)."""
template = _make_template(
tmp_path, "{{config.__class__.__init__.__globals__}}"
)
output = tmp_path / "output.docx"
TemplateRenderer().render_template(template, {"config": {}}, output)
text = _read_text(output)
# No globals should leak
assert "builtins" not in text.lower()
assert "import" not in text.lower()
def test_missing_variable(tmp_path: Path) -> None:
"""Missing variable in data dict — Jinja2 default behavior (empty string)."""
template = _make_template(tmp_path, "Hello, {{name}}!")
output = tmp_path / "output.docx"
# With no 'name' in data, Jinja2 SandboxedEnvironment defaults to undefined
# which renders as empty string (not an error)
TemplateRenderer().render_template(template, {}, output)
text = _read_text(output)
# The placeholder should be gone (replaced with empty)
assert "{{name}}" not in text