*/ private function context(): array { return [ 'title' => 'Security check report', 'customer_name' => 'Acme', 'customer_no' => 'D-100', 'domain' => 'acme.de', 'product' => 'Intranet', 'owner' => 'Jane Doe', 'status_label' => 'In progress', 'generated_at' => '2026-06-22 10:00', 'app_name' => 'CoreCore', 'logo_data_uri' => 'data:image/png;base64,AAAA', 'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3], 'tech_sections' => [ ['label' => 'Transport', 'items' => [ ['label' => 'TLS enforced', 'done' => true, 'note' => 'TLS 1.3'], ['label' => 'HSTS header', 'done' => false, 'note' => ''], ]], ], 'steps' => [ ['number' => 1, 'label' => 'Kickoff', 'done' => true, 'completed_at' => '2026-06-18'], ], ]; } public function testRenderSubstitutesScalarsAndEscapesData(): void { $service = new SecurityReportTemplateService(); $out = $service->render( '

{{title}}

who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}', $this->context() ); $this->assertStringContainsString('

Security check report

', $out); $this->assertStringContainsString('dom=acme.de', $out); $this->assertStringContainsString('owner=Jane Doe', $out); $this->assertStringContainsString('p=72%', $out); $this->assertStringContainsString('src=data:image/png;base64,AAAA', $out); // Customer data is HTML-escaped so it cannot break out of the template markup. $this->assertStringContainsString('who=<b>Acme</b>', $out); $this->assertStringNotContainsString('Acme', $out); } public function testRenderBuildsChecklistAndProcessHtml(): void { $service = new SecurityReportTemplateService(); $out = $service->render('{{checklist}}||{{process}}', $this->context()); $this->assertStringContainsString('Transport', $out); $this->assertStringContainsString('TLS enforced', $out); $this->assertStringContainsString('TLS 1.3', $out); $this->assertStringContainsString('Kickoff', $out); $this->assertStringContainsString('', $out); } public function testRenderIsSinglePassAndDoesNotReExpandInjectedTokens(): void { $service = new SecurityReportTemplateService(); $context = $this->context(); $context['customer_name'] = '{{title}}'; $context['title'] = 'INJECTED'; $out = $service->render('{{customer_name}}', $context); // The value substituted for {{customer_name}} must not be re-scanned, // so the literal token survives and {{title}} is never expanded here. $this->assertStringContainsString('{{title}}', $out); $this->assertStringNotContainsString('INJECTED', $out); } public function testRenderHandlesEmptyContextSafely(): void { $service = new SecurityReportTemplateService(); $out = $service->render('[{{title}}]({{checklist}})', []); $this->assertStringContainsString('[]', $out); // No sections -> the "no items" notice instead of a table. $this->assertStringContainsString('class="muted"', $out); } public function testDefaultTemplateContainsCoreVariables(): void { $service = new SecurityReportTemplateService(); $html = $service->defaultTemplateHtml(); $this->assertNotSame('', $html); $this->assertStringContainsString('{{logo_src}}', $html); $this->assertStringContainsString('{{checklist}}', $html); $this->assertStringContainsString('{{process}}', $html); } public function testAvailableVariablesExposeStructuralTokens(): void { $variables = (new SecurityReportTemplateService())->availableVariables(); $this->assertArrayHasKey('logo_src', $variables); $this->assertArrayHasKey('checklist', $variables); $this->assertArrayHasKey('process', $variables); } }