mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
create agent (#6520)
Added the create agent. Use [these prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md) to test or try your own :) Here’s the one I use ``` Hey, I need to generate an employee expense report for reimbursement. Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: [email protected] Employee details: * Employee Name: Michael Tran * Employee ID: EMP-1047 * Department: Client Services * Report Date: January 20th, 2026 * Reporting Period: January 5th, 2026 – January 16th, 2026 * Manager Approver: Laura Simmons Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA. Expense items: * Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket * Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026 * Taxi from airport to hotel — $48.00 — January 5th, 2026 * Client dinner (3 attendees) — $186.20 — January 6th, 2026 * Parking at JFK Airport — $72.00 — January 5th-8th, 2026 * Breakfast (per diem not used) — $18.50 — January 7th, 2026 * Uber to client office — $22.10 — January 7th, 2026 * Printing + presentation materials — $46.90 — January 8th, 2026 * Lunch with client — $39.75 — January 8th, 2026 * Office supplies (notebooks, pens) — $27.60 — January 10th, 2026 * Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026 * Team lunch meeting (internal) — $64.30 — January 15th, 2026 Reimbursement method should be direct deposit. Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy." ``` --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
9b877d4f8d
commit
88adb7adad
@@ -0,0 +1,564 @@
|
||||
"""Tests for the chunked PdfCreateAgent pipeline.
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
3. _safe_filename produces clean slugs
|
||||
4. _make_chunks groups sections correctly by token budget
|
||||
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
|
||||
6. orchestrate() returns EditCannotDoResponse when meta planner signals cannot_do
|
||||
7. orchestrate() returns EditCannotDoResponse when sections planner returns empty list
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai.profiles import ModelProfile
|
||||
|
||||
from stirling.agents.pdf_create.agent import (
|
||||
PdfCreateAgent,
|
||||
_make_chunks,
|
||||
_safe_filename,
|
||||
)
|
||||
from stirling.contracts import (
|
||||
EditCannotDoResponse,
|
||||
EditPlanResponse,
|
||||
OrchestratorRequest,
|
||||
)
|
||||
from stirling.contracts.pdf_create import (
|
||||
BulletListSection,
|
||||
DocumentMeta,
|
||||
DocumentSections,
|
||||
DocumentStyle,
|
||||
GeneratedDocument,
|
||||
KeyValueSection,
|
||||
LineItemsSection,
|
||||
PlannedSection,
|
||||
SectionDepth,
|
||||
SectionType,
|
||||
SignatureSection,
|
||||
TextSection,
|
||||
WrittenSections,
|
||||
)
|
||||
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
|
||||
from stirling.services import build_runtime
|
||||
from stirling.services.runtime import AppRuntime
|
||||
|
||||
_NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime() -> AppRuntime:
|
||||
return build_runtime(build_app_settings())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(runtime: AppRuntime) -> PdfCreateAgent:
|
||||
return PdfCreateAgent(runtime)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _invoice_doc() -> GeneratedDocument:
|
||||
return GeneratedDocument(
|
||||
title="Invoice",
|
||||
subtitle="Acme Corp",
|
||||
reference_number="Invoice #INV-001",
|
||||
sections=[
|
||||
KeyValueSection(
|
||||
heading="Details",
|
||||
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
|
||||
),
|
||||
LineItemsSection(
|
||||
heading="Line Items",
|
||||
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||
rows=[
|
||||
["Consulting services", "10", "$500.00", "$5,000.00"],
|
||||
["Expenses", "1", "$200.00", "$200.00"],
|
||||
],
|
||||
total_row=["Total", "", "", "$5,200.00"],
|
||||
),
|
||||
TextSection(
|
||||
heading="Payment Terms",
|
||||
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
|
||||
),
|
||||
SignatureSection(
|
||||
heading="Authorised By",
|
||||
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _simple_meta() -> DocumentMeta:
|
||||
return DocumentMeta(
|
||||
title="Invoice",
|
||||
subtitle="Acme Corp",
|
||||
tone_brief="Professional business tone.",
|
||||
shared_terms={"the Client": "Acme Corp"},
|
||||
)
|
||||
|
||||
|
||||
def _simple_sections() -> DocumentSections:
|
||||
return DocumentSections(
|
||||
sections=[
|
||||
PlannedSection(
|
||||
heading="Details",
|
||||
type=SectionType.KEY_VALUE,
|
||||
depth=SectionDepth.BRIEF,
|
||||
key_points=["Date: 2026-05-06", "Due: 2026-06-06"],
|
||||
),
|
||||
PlannedSection(
|
||||
heading="Line Items",
|
||||
type=SectionType.LINE_ITEMS,
|
||||
depth=SectionDepth.STANDARD,
|
||||
key_points=["Consulting services, 10h, $500/h", "Expenses, $200"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _written_sections() -> WrittenSections:
|
||||
return WrittenSections(
|
||||
sections=[
|
||||
KeyValueSection(
|
||||
heading="Details",
|
||||
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06")],
|
||||
),
|
||||
LineItemsSection(
|
||||
heading="Line Items",
|
||||
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||
rows=[["Consulting services", "10", "$500.00", "$5,000.00"]],
|
||||
total_row=["Total", "", "", "$5,000.00"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _orchestrator_request(message: str = "Create an invoice for Acme Corp") -> OrchestratorRequest:
|
||||
return OrchestratorRequest(
|
||||
user_message=message,
|
||||
files=[],
|
||||
conversation_history=[],
|
||||
artifacts=[],
|
||||
enabled_endpoints=[],
|
||||
)
|
||||
|
||||
|
||||
# ── Section model validation ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_text_section_round_trips() -> None:
|
||||
s = TextSection(heading="Summary", body="Hello\n\nWorld")
|
||||
assert s.type == "text"
|
||||
assert s.heading == "Summary"
|
||||
assert "World" in s.body
|
||||
|
||||
|
||||
def test_key_value_section_round_trips() -> None:
|
||||
s = KeyValueSection(pairs=[("Name", "Alice"), ("Role", "Engineer")])
|
||||
assert s.type == "key_value"
|
||||
assert s.pairs[0] == ("Name", "Alice")
|
||||
|
||||
|
||||
def test_line_items_section_with_total_row() -> None:
|
||||
s = LineItemsSection(
|
||||
columns=["Item", "Amount"],
|
||||
rows=[["Widget", "$10"]],
|
||||
total_row=["Total", "$10"],
|
||||
)
|
||||
assert s.total_row is not None
|
||||
assert s.total_row[1] == "$10"
|
||||
|
||||
|
||||
def test_line_items_section_optional_total_row() -> None:
|
||||
s = LineItemsSection(columns=["Item", "Amount"], rows=[["Widget", "$10"]])
|
||||
assert s.total_row is None
|
||||
|
||||
|
||||
def test_bullet_list_section_round_trips() -> None:
|
||||
s = BulletListSection(items=["Alpha", "Beta", "Gamma"])
|
||||
assert s.type == "bullet_list"
|
||||
assert len(s.items) == 3
|
||||
|
||||
|
||||
def test_signature_section_round_trips() -> None:
|
||||
s = SignatureSection(signatories=["Alice", "Bob"])
|
||||
assert s.type == "signature"
|
||||
assert "Alice" in s.signatories
|
||||
|
||||
|
||||
def test_generated_document_optional_fields() -> None:
|
||||
doc = GeneratedDocument(title="Simple Doc", sections=[TextSection(body="Hello")])
|
||||
assert doc.subtitle is None
|
||||
assert doc.reference_number is None
|
||||
|
||||
|
||||
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_produces_html(agent: PdfCreateAgent) -> None:
|
||||
doc = _invoice_doc()
|
||||
html = agent._render(doc)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Invoice" in html
|
||||
assert "INV-001" in html
|
||||
|
||||
|
||||
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="All Sections",
|
||||
sections=[
|
||||
TextSection(body="Some prose text."),
|
||||
KeyValueSection(pairs=[("Key", "Value")]),
|
||||
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
|
||||
BulletListSection(items=["item one"]),
|
||||
SignatureSection(signatories=["Alice"]),
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Some prose text." in html
|
||||
assert "Key" in html and "Value" in html
|
||||
assert "<th>" in html
|
||||
assert "item one" in html
|
||||
assert "Alice" in html
|
||||
|
||||
|
||||
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="XSS Test",
|
||||
sections=[TextSection(body="<script>alert('xss')</script>")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[
|
||||
LineItemsSection(
|
||||
columns=["Item", "Total"],
|
||||
rows=[["Widget", "$10"]],
|
||||
total_row=["Total", "$10"],
|
||||
)
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "total-row" in html
|
||||
|
||||
|
||||
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<tfoot>" not in html
|
||||
|
||||
|
||||
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="My Doc",
|
||||
subtitle="Subtitle Here",
|
||||
reference_number="REF-42",
|
||||
sections=[TextSection(body="Content.")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Subtitle Here" in html
|
||||
assert "REF-42" in html
|
||||
|
||||
|
||||
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_safe_filename_basic() -> None:
|
||||
assert _safe_filename("My Invoice") == "my-invoice.pdf"
|
||||
|
||||
|
||||
def test_safe_filename_strips_special_chars() -> None:
|
||||
assert _safe_filename("Report: Q1/2026!") == "report-q12026.pdf"
|
||||
|
||||
|
||||
def test_safe_filename_empty_title() -> None:
|
||||
assert _safe_filename("!!!") == "document.pdf"
|
||||
|
||||
|
||||
# ── _make_chunks ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _planned(heading: str, depth: SectionDepth) -> PlannedSection:
|
||||
return PlannedSection(
|
||||
heading=heading,
|
||||
type=SectionType.TEXT,
|
||||
depth=depth,
|
||||
key_points=["placeholder"],
|
||||
)
|
||||
|
||||
|
||||
def test_make_chunks_empty_returns_empty() -> None:
|
||||
assert _make_chunks([]) == []
|
||||
|
||||
|
||||
def test_make_chunks_single_section_is_one_chunk() -> None:
|
||||
chunks = _make_chunks([_planned("Intro", SectionDepth.STANDARD)])
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].index == 0
|
||||
assert len(chunks[0].sections) == 1
|
||||
assert chunks[0].context_before is None
|
||||
assert chunks[0].context_after is None
|
||||
|
||||
|
||||
def test_make_chunks_groups_under_ceiling() -> None:
|
||||
# 5 × STANDARD (550 each) = 2750 — fits in one chunk under ceiling of 3000
|
||||
sections = [_planned(f"Section {i}", SectionDepth.STANDARD) for i in range(5)]
|
||||
chunks = _make_chunks(sections)
|
||||
assert len(chunks) == 1
|
||||
assert len(chunks[0].sections) == 5
|
||||
|
||||
|
||||
def test_make_chunks_splits_when_over_ceiling() -> None:
|
||||
# 6 × STANDARD (550 each) = 3300 — must split (3000 ceiling)
|
||||
# First chunk: 5 sections (2750), second: 1 section (550)
|
||||
sections = [_planned(f"Section {i}", SectionDepth.STANDARD) for i in range(6)]
|
||||
chunks = _make_chunks(sections)
|
||||
assert len(chunks) == 2
|
||||
assert len(chunks[0].sections) == 5
|
||||
assert len(chunks[1].sections) == 1
|
||||
|
||||
|
||||
def test_make_chunks_oversized_section_gets_own_chunk() -> None:
|
||||
# DETAILED (1200) + 3×STANDARD (1650) = 2850; adding a 4th STANDARD (550) = 3400 > 3000.
|
||||
# So first chunk holds DETAILED + 3 STANDARDs; last STANDARD spills to chunk 2.
|
||||
sections = [
|
||||
_planned("Big Table", SectionDepth.DETAILED),
|
||||
_planned("Terms", SectionDepth.STANDARD),
|
||||
_planned("Notes", SectionDepth.STANDARD),
|
||||
_planned("Extra", SectionDepth.STANDARD),
|
||||
_planned("Appendix", SectionDepth.STANDARD),
|
||||
]
|
||||
chunks = _make_chunks(sections)
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].sections[0].heading == "Big Table"
|
||||
assert len(chunks[0].sections) == 4
|
||||
assert chunks[1].sections[0].heading == "Appendix"
|
||||
|
||||
|
||||
def test_make_chunks_neighbour_context() -> None:
|
||||
# 6 × STANDARD (550 each) = 3300 → splits into chunk of 5 (2750) + chunk of 1 (550)
|
||||
many = [_planned(f"S{i}", SectionDepth.STANDARD) for i in range(5)]
|
||||
many.append(_planned("Last", SectionDepth.STANDARD))
|
||||
chunks = _make_chunks(many)
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].context_after is not None
|
||||
assert chunks[1].context_before is not None
|
||||
assert chunks[0].context_before is None
|
||||
assert chunks[1].context_after is None
|
||||
|
||||
|
||||
def test_make_chunks_preserves_section_order() -> None:
|
||||
headings = [f"Section {i}" for i in range(8)]
|
||||
sections = [_planned(h, SectionDepth.STANDARD) for h in headings]
|
||||
chunks = _make_chunks(sections)
|
||||
reassembled = [s.heading for chunk in chunks for s in chunk.sections]
|
||||
assert reassembled == headings
|
||||
|
||||
|
||||
# ── orchestrate() ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
|
||||
meta = _simple_meta()
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
|
||||
with (
|
||||
agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||
),
|
||||
agent._sections_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||
),
|
||||
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request())
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
assert len(result.steps) == 1
|
||||
step = result.steps[0]
|
||||
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
|
||||
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
|
||||
assert step.parameters.filename.endswith(".pdf")
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_cannot_do_from_planner(agent: PdfCreateAgent) -> None:
|
||||
cannot_do_meta = DocumentMeta(cannot_do_reason="This is not a document creation request.")
|
||||
|
||||
with agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=cannot_do_meta.model_dump_json())
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request("what is 2+2?"))
|
||||
|
||||
assert isinstance(result, EditCannotDoResponse)
|
||||
assert "not a document" in result.reason
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_empty_sections_returns_cannot_do(agent: PdfCreateAgent) -> None:
|
||||
meta = DocumentMeta(title="Empty", tone_brief=".")
|
||||
empty_sections = DocumentSections(sections=[])
|
||||
|
||||
with (
|
||||
agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||
),
|
||||
agent._sections_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=empty_sections.model_dump_json())
|
||||
),
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request("do the thing"))
|
||||
|
||||
assert isinstance(result, EditCannotDoResponse)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> None:
|
||||
"""Two chunks of written sections are assembled in order into one document."""
|
||||
meta = DocumentMeta(title="Multi-Chunk Doc", tone_brief="Formal.")
|
||||
sections = DocumentSections(
|
||||
sections=[
|
||||
PlannedSection(heading="Intro", type=SectionType.TEXT, depth=SectionDepth.BRIEF, key_points=["x"]),
|
||||
PlannedSection(
|
||||
heading="Details",
|
||||
type=SectionType.KEY_VALUE,
|
||||
depth=SectionDepth.BRIEF,
|
||||
key_points=["y"],
|
||||
),
|
||||
]
|
||||
)
|
||||
# The writer override is shared across all parallel calls, so use a combined
|
||||
# WrittenSections that contains all sections — both chunks will return the same
|
||||
# payload and we verify the final HTML contains the expected content.
|
||||
combined = WrittenSections(
|
||||
sections=[
|
||||
TextSection(heading="Intro", body="Introduction text."),
|
||||
KeyValueSection(heading="Details", pairs=[("Key", "Value")]),
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||
),
|
||||
agent._sections_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||
),
|
||||
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=combined.model_dump_json())),
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent) -> None:
|
||||
"""Style extracted by the meta planner is applied to the rendered HTML."""
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="magenta",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
|
||||
with (
|
||||
agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||
),
|
||||
agent._sections_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||
),
|
||||
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
|
||||
|
||||
def test_render_applies_style(agent: PdfCreateAgent) -> None:
|
||||
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
|
||||
doc = GeneratedDocument(
|
||||
title="Styled",
|
||||
sections=[TextSection(body="Content.")],
|
||||
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "--color-primary: magenta" in html
|
||||
assert "--color-bg: #111111" in html
|
||||
|
||||
|
||||
def test_document_style_drops_unsafe_colors() -> None:
|
||||
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
|
||||
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#fff",
|
||||
)
|
||||
|
||||
unsafe = DocumentStyle(
|
||||
primary_color="red; background: url(http://evil.test/steal)",
|
||||
background_color="expression(alert(1))",
|
||||
body_text_color="navy; }",
|
||||
)
|
||||
assert unsafe.primary_color is None
|
||||
assert unsafe.background_color is None
|
||||
assert unsafe.body_text_color is None
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
|
||||
with (
|
||||
agent._meta_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
|
||||
),
|
||||
agent._sections_planner.override(
|
||||
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
|
||||
),
|
||||
agent._writer.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=written.model_dump_json())),
|
||||
):
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
Reference in New Issue
Block a user