Added security check report settings and templating

This commit is contained in:
2026-06-22 14:00:14 +02:00
parent 0b7c08ba6b
commit 7a9f8e770a
18 changed files with 1120 additions and 12 deletions

View File

@@ -0,0 +1,81 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use PHPUnit\Framework\TestCase;
/**
* Service-behaviour test. As with TenantLogoServiceTest, the successful upload
* path is asserted indirectly (MIME/size guards, no directory creation on
* rejection) because move_uploaded_file() needs a real HTTP-uploaded file and
* cannot run under phpunit. The move step is covered by manual smoke testing.
*/
class SecurityReportLogoServiceTest extends TestCase
{
private SecurityReportLogoService $service;
protected function setUp(): void
{
$this->service = new SecurityReportLogoService();
}
public function testReportLogoDirIsScopedToSecurityReportLogo(): void
{
$this->assertStringEndsWith('/security/report-logo', $this->service->reportLogoDir());
}
public function testSaveUploadRejectsMissingFile(): void
{
$this->assertFalse($this->service->saveUpload([])['ok']);
}
public function testSaveUploadRejectsUploadError(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_NO_FILE,
'size' => 100,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsOversizedFile(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_OK,
'size' => 6 * 1024 * 1024,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsNonImageContent(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'sec-logo-test');
$this->assertNotFalse($tmp);
file_put_contents($tmp, "just some plain text, not an image\n");
try {
$result = $this->service->saveUpload([
'tmp_name' => $tmp,
'error' => UPLOAD_ERR_OK,
'size' => filesize($tmp),
]);
$this->assertFalse($result['ok']);
// Rejection must happen before any directory/file is created.
$this->assertFalse(is_dir($this->service->reportLogoDir() . '/__never__'));
} finally {
@unlink($tmp);
}
}
public function testDeleteIsIdempotentWhenNothingStored(): void
{
// delete() is safe to call even when the directory does not exist.
$this->assertTrue($this->service->delete());
}
}

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use MintyPHP\Service\Branding\BrandingLogoService;
use PHPUnit\Framework\TestCase;
@@ -48,6 +50,7 @@ class SecurityReportPdfServiceTest extends TestCase
'domain_no' => 'DNS-1',
'product_name' => 'Intranet',
'product_code' => 'intranet',
'owner_name' => 'Jane Doe',
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
'process_state' => $processState,
'tech_schema_snapshot' => $snapshot,
@@ -64,6 +67,7 @@ class SecurityReportPdfServiceTest extends TestCase
$this->assertSame('D-100', $context['customer_no']);
$this->assertSame('acme.de', $context['domain']);
$this->assertSame('Intranet', $context['product']);
$this->assertSame('Jane Doe', $context['owner']);
$this->assertNotSame('', $context['title']);
// Summary mirrors the progress math (3 tech items, 2 done).
@@ -126,4 +130,28 @@ class SecurityReportPdfServiceTest extends TestCase
$this->assertNotSame('', $pdf);
$this->assertStringStartsWith('%PDF-', $pdf);
}
public function testRenderCustomerReportPdfUsesConfiguredCustomTemplate(): void
{
$branding = $this->createMock(BrandingLogoService::class);
$branding->method('hasLogo')->willReturn(false);
$settings = $this->createMock(SecurityReportSettingsGateway::class);
$settings->method('getTemplateHtml')->willReturn(
'<!doctype html><html><body><h1>Custom {{customer_name}}</h1>{{checklist}}</body></html>'
);
$service = new SecurityReportPdfService(
new SecurityCheckProcess(),
$branding,
null,
$settings,
new SecurityReportTemplateService(),
null
);
$pdf = $service->renderCustomerReportPdf($this->enrichedCheck(), ['app_name' => 'CoreCore']);
$this->assertStringStartsWith('%PDF-', $pdf);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SecurityReportSettingsGatewayTest extends TestCase
{
/**
* @param array<string, string> $values
*/
private function makeGateway(array $values = []): SecurityReportSettingsGateway
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
$metadata->method('set')->willReturn(true);
return new SecurityReportSettingsGateway($metadata);
}
public function testTemplateDefaultsToEmpty(): void
{
$gateway = $this->makeGateway();
$this->assertSame('', $gateway->getTemplateHtml());
$this->assertFalse($gateway->hasCustomTemplate());
}
public function testGetTemplateHtmlReturnsTrimmedStoredValue(): void
{
$gateway = $this->makeGateway([
SecurityReportSettingsGateway::KEY_TEMPLATE_HTML => " <h1>{{title}}</h1> ",
]);
$this->assertSame('<h1>{{title}}</h1>', $gateway->getTemplateHtml());
$this->assertTrue($gateway->hasCustomTemplate());
}
public function testSetTemplateHtmlRejectsOversizedTemplate(): void
{
$gateway = $this->makeGateway();
$tooLong = str_repeat('a', SecurityReportSettingsGateway::MAX_TEMPLATE_LENGTH + 1);
$this->assertFalse($gateway->setTemplateHtml($tooLong));
}
public function testSetTemplateHtmlPersistsValueAndClearsOnEmpty(): void
{
$writes = [];
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn(null);
$metadata->method('set')->willReturnCallback(static function (string $key, ?string $value, ?string $description = null) use (&$writes): bool {
$writes[$key] = $value;
return true;
});
$gateway = new SecurityReportSettingsGateway($metadata);
$this->assertTrue($gateway->setTemplateHtml('<p>x</p>'));
$this->assertSame('<p>x</p>', $writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
// Blank input clears the setting (restores the built-in default).
$this->assertTrue($gateway->setTemplateHtml(' '));
$this->assertNull($writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use PHPUnit\Framework\TestCase;
class SecurityReportTemplateServiceTest extends TestCase
{
/**
* @return array<string, mixed>
*/
private function context(): array
{
return [
'title' => 'Security check report',
'customer_name' => '<b>Acme</b>',
'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(
'<h1>{{title}}</h1> who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}',
$this->context()
);
$this->assertStringContainsString('<h1>Security check report</h1>', $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=&lt;b&gt;Acme&lt;/b&gt;', $out);
$this->assertStringNotContainsString('<b>Acme</b>', $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('<table class="checklist">', $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);
}
}