147 lines
5.5 KiB
Python
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
|