"""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