First version of the security module
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityBcGatewayTest extends TestCase
|
||||
{
|
||||
private function makeGateway(bool $configured = false): SecurityBcGateway
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($configured);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
|
||||
$oauth = $this->createMock(SecurityOAuthTokenService::class);
|
||||
$session = $this->createMock(SessionStoreInterface::class);
|
||||
$session->method('all')->willReturn([]);
|
||||
|
||||
return new SecurityBcGateway($settings, $oauth, $session);
|
||||
}
|
||||
|
||||
public function testSearchCustomersReturnsEmptyForBlankQuery(): void
|
||||
{
|
||||
$this->assertSame([], $this->makeGateway()->searchCustomers(' '));
|
||||
}
|
||||
|
||||
public function testListDomainsForCustomerReturnsEmptyForBlankNumber(): void
|
||||
{
|
||||
$this->assertSame([], $this->makeGateway()->listDomainsForCustomer(''));
|
||||
}
|
||||
|
||||
public function testListDomainsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
// request() short-circuits to null before any network call when unconfigured.
|
||||
$this->assertSame([], $this->makeGateway(false)->listDomainsForCustomer('D10001'));
|
||||
}
|
||||
|
||||
public function testSearchCustomersRejectsInvalidCharacters(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->makeGateway(true)->searchCustomers("bad'); DROP TABLE--");
|
||||
}
|
||||
|
||||
public function testTestConnectionReportsIncompleteConfiguration(): void
|
||||
{
|
||||
$result = $this->makeGateway(false)->testConnection();
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('error', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityBcSettingsGatewayTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $values
|
||||
*/
|
||||
private function makeGateway(array $values = []): SecurityBcSettingsGateway
|
||||
{
|
||||
$metadata = $this->createMock(SettingsMetadataGateway::class);
|
||||
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
|
||||
$metadata->method('set')->willReturn(true);
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('encryptString')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
|
||||
$crypto->method('decryptString')->willReturnCallback(static fn (string $v): string => str_replace('enc:', '', $v));
|
||||
|
||||
return new SecurityBcSettingsGateway($metadata, $crypto);
|
||||
}
|
||||
|
||||
public function testAuthModeDefaultsToBasic(): void
|
||||
{
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_BASIC, $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testAuthModeReturnsOauth2WhenSet(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([SecurityBcSettingsGateway::KEY_AUTH_MODE => 'oauth2']);
|
||||
|
||||
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2, $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testBuildEntityUrlComposesCompanyAndEntity(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com/ODataV4/',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme GmbH',
|
||||
]);
|
||||
|
||||
$url = $gateway->buildEntityUrl('Integration_Customer_Card');
|
||||
|
||||
$this->assertSame("https://bc.example.com/ODataV4/Company('Acme%20GmbH')/Integration_Customer_Card", $url);
|
||||
}
|
||||
|
||||
public function testValidateConfigurationReportsMissingBasicCredentials(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
|
||||
]);
|
||||
|
||||
$missing = $gateway->validateConfiguration();
|
||||
|
||||
$this->assertNotEmpty($missing);
|
||||
$this->assertFalse($gateway->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsConfiguredWhenBasicComplete(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
|
||||
SecurityBcSettingsGateway::KEY_BASIC_USER => 'svc',
|
||||
SecurityBcSettingsGateway::KEY_BASIC_PASSWORD_ENC => 'enc:secret',
|
||||
]);
|
||||
|
||||
$this->assertTrue($gateway->isConfigured());
|
||||
$this->assertSame('secret', $gateway->getBasicPassword());
|
||||
}
|
||||
|
||||
public function testSetODataBaseUrlRejectsNonHttps(): void
|
||||
{
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckProcessTest extends TestCase
|
||||
{
|
||||
public function testStepsContainSixOrderedSteps(): void
|
||||
{
|
||||
$steps = (new SecurityCheckProcess())->steps();
|
||||
|
||||
$this->assertCount(6, $steps);
|
||||
$this->assertSame('beauftragung', $steps[0]['key']);
|
||||
$this->assertSame(SecurityCheckProcess::STEP_PERFORM_CHECK, $steps[4]['key']);
|
||||
$this->assertSame('report', $steps[5]['key']);
|
||||
}
|
||||
|
||||
public function testManualStepKeysExcludeTechChecklistStep(): void
|
||||
{
|
||||
$keys = (new SecurityCheckProcess())->manualStepKeys();
|
||||
|
||||
$this->assertCount(5, $keys);
|
||||
$this->assertNotContains(SecurityCheckProcess::STEP_PERFORM_CHECK, $keys);
|
||||
$this->assertContains(SecurityCheckProcess::STEP_INFORMATION, $keys);
|
||||
}
|
||||
|
||||
public function testInfoStepFieldsAreDefined(): void
|
||||
{
|
||||
$fields = (new SecurityCheckProcess())->stepFields(SecurityCheckProcess::STEP_INFORMATION);
|
||||
|
||||
$this->assertNotEmpty($fields);
|
||||
$keys = array_column($fields, 'key');
|
||||
$this->assertContains('technical_contact', $keys);
|
||||
$this->assertContains('external_systems', $keys);
|
||||
}
|
||||
|
||||
public function testRequiredFieldKeysExcludeOptionalFields(): void
|
||||
{
|
||||
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);
|
||||
|
||||
$this->assertContains('technical_contact', $keys);
|
||||
$this->assertContains('deployment', $keys);
|
||||
$this->assertContains('system_info', $keys);
|
||||
$this->assertContains('external_systems', $keys);
|
||||
$this->assertNotContains('special_notes', $keys);
|
||||
}
|
||||
|
||||
public function testSchedulingAndBlockerStepsRequireTwoDatetimeFields(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$expected = [
|
||||
'scheduling' => ['golive_start', 'golive_end'],
|
||||
'blocker_appointment' => ['blocker_start', 'blocker_end'],
|
||||
];
|
||||
|
||||
foreach ($expected as $stepKey => $expectedKeys) {
|
||||
$fields = $process->stepFields($stepKey);
|
||||
$this->assertCount(2, $fields);
|
||||
foreach ($fields as $field) {
|
||||
$this->assertSame(SecurityCheckProcess::FIELD_DATETIME, $field['type']);
|
||||
$this->assertTrue($field['required']);
|
||||
}
|
||||
$this->assertSame($expectedKeys, $process->requiredFieldKeys($stepKey));
|
||||
}
|
||||
}
|
||||
|
||||
public function testTechCheckItemsExtractsOnlyCheckItemsWithKeys(): void
|
||||
{
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'section', 'label' => 'Group'],
|
||||
['type' => 'check', 'key' => 'tls', 'label' => 'TLS'],
|
||||
['type' => 'check', 'key' => '', 'label' => 'No key'],
|
||||
['type' => 'check', 'label' => 'Missing key'],
|
||||
'not-an-array',
|
||||
]];
|
||||
|
||||
$items = SecurityCheckProcess::techCheckItems($snapshot);
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame('tls', $items[0]['key']);
|
||||
}
|
||||
|
||||
public function testComputeProgressOpenWhenNothingDone(): void
|
||||
{
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], [], []);
|
||||
|
||||
$this->assertSame(5, $progress['manual_total']);
|
||||
$this->assertSame(0, $progress['tech_total']);
|
||||
$this->assertSame(0, $progress['done']);
|
||||
$this->assertSame(0, $progress['percent']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $progress['status']);
|
||||
$this->assertTrue($progress['step6_done']); // no tech items → trivially done
|
||||
}
|
||||
|
||||
public function testComputeProgressInProgressWhenPartiallyDone(): void
|
||||
{
|
||||
$processState = ['beauftragung' => ['done' => true]];
|
||||
|
||||
$progress = (new SecurityCheckProcess())->computeProgress($processState, [], []);
|
||||
|
||||
$this->assertSame(1, $progress['done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressCompletedWhenAllDone(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$techState = ['a' => ['done' => true], 'b' => ['done' => true]];
|
||||
|
||||
$progress = $process->computeProgress($processState, $snapshot, $techState);
|
||||
|
||||
$this->assertSame(7, $progress['total']); // 5 manual + 2 tech
|
||||
$this->assertSame(7, $progress['done']);
|
||||
$this->assertSame(100, $progress['percent']);
|
||||
$this->assertTrue($progress['step6_done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressStep6NotDoneWhenSomeTechItemsOpen(): void
|
||||
{
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], $snapshot, $techState);
|
||||
|
||||
$this->assertFalse($progress['step6_done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressArchivedOverridesDerivedStatus(): void
|
||||
{
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], [], [], true);
|
||||
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $progress['status']);
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesMetWhenAllPrecedingStepsComplete(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
// The report step itself is intentionally left open — it does not gate itself.
|
||||
$processState[$key] = ['done' => $key !== SecurityCheckProcess::STEP_REPORT];
|
||||
}
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$this->assertTrue($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesNotMetWhenAManualStepIsOpen(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$processState['scheduling']['done'] = false; // one preceding step still open
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesNotMetWhenTechChecklistIncomplete(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$techState = ['a' => ['done' => true]]; // 'b' still open
|
||||
|
||||
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesMetWithEmptyTechChecklist(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
|
||||
// No tech items → checklist trivially complete; only the manual steps gate.
|
||||
$this->assertTrue($process->reportPrerequisitesMet($processState, [], []));
|
||||
}
|
||||
|
||||
public function testStatusVariantMapping(): void
|
||||
{
|
||||
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_OPEN));
|
||||
$this->assertSame('warning', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_IN_PROGRESS));
|
||||
$this->assertSame('success', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_COMPLETED));
|
||||
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_ARCHIVED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
|
||||
/**
|
||||
* @return array{0: SecurityCheckService, 1: SecurityCheckRepository, 2: SecurityCheckTemplateService}
|
||||
*/
|
||||
private function makeService(?SecurityCheckRepository $repo = null, ?SecurityCheckTemplateService $templates = null): array
|
||||
{
|
||||
$repo = $repo ?? $this->createMock(SecurityCheckRepository::class);
|
||||
$templates = $templates ?? $this->createMock(SecurityCheckTemplateService::class);
|
||||
$service = new SecurityCheckService($repo, $templates, new SecurityCheckProcess());
|
||||
|
||||
return [$service, $repo, $templates];
|
||||
}
|
||||
|
||||
public function testCreateRequiresCustomerDomainAndProduct(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->expects($this->never())->method('insert');
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('debitor_no', $result['errors']);
|
||||
$this->assertArrayHasKey('domain_no', $result['errors']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateFailsWhenTemplateMissing(): void
|
||||
{
|
||||
$templates = $this->createMock(SecurityCheckTemplateService::class);
|
||||
$templates->method('findByCode')->willReturn(null);
|
||||
[$service] = $this->makeService(null, $templates);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [
|
||||
'debitor_no' => 'D1',
|
||||
'domain_no' => 'DNS1',
|
||||
'product_code' => 'intranet',
|
||||
], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateSucceedsAndSnapshotsSchema(): void
|
||||
{
|
||||
$templates = $this->createMock(SecurityCheckTemplateService::class);
|
||||
$templates->method('findByCode')->willReturn(['id' => 7, 'product_name' => 'Intranet', 'active' => 1]);
|
||||
$templates->method('decodeSchema')->willReturn(['version' => 2, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
|
||||
|
||||
$captured = null;
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('insert')->willReturnCallback(function (int $tenantId, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return 42;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo, $templates);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [
|
||||
'debitor_no' => 'D1',
|
||||
'debitor_name' => 'Acme',
|
||||
'domain_no' => 'DNS1',
|
||||
'domain_url' => 'acme.de',
|
||||
'product_code' => 'intranet',
|
||||
'owner_user_id' => 9,
|
||||
], 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(42, $result['id']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $captured['status']);
|
||||
$this->assertSame(7, $captured['template_id']);
|
||||
$this->assertSame('Intranet', $captured['product_name']);
|
||||
$snapshot = json_decode((string) $captured['tech_schema_snapshot_json'], true);
|
||||
$this->assertSame('tls', $snapshot['items'][0]['key']);
|
||||
}
|
||||
|
||||
public function testSaveStateStampsCompletionAndDerivesCompletedStatus(): void
|
||||
{
|
||||
$snapshot = json_encode(['version' => 1, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => $snapshot,
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$process = new SecurityCheckProcess();
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$step = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$step[$key] = ['done' => '1', 'note' => ''];
|
||||
}
|
||||
$step[SecurityCheckProcess::STEP_INFORMATION]['fields'] = [
|
||||
'technical_contact' => 'Jane',
|
||||
'deployment' => 'Cloud',
|
||||
'system_info' => 'PHP 8.5',
|
||||
'external_systems' => 'https://api.example.test',
|
||||
];
|
||||
$step['scheduling']['fields'] = [
|
||||
'golive_start' => '2026-07-01T09:00',
|
||||
'golive_end' => '2026-07-01T12:00',
|
||||
];
|
||||
$step['blocker_appointment']['fields'] = [
|
||||
'blocker_start' => '2026-07-01T08:00',
|
||||
'blocker_end' => '2026-07-02T18:00',
|
||||
];
|
||||
$input = [
|
||||
'step' => $step,
|
||||
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
|
||||
];
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, $input, 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertTrue($process_state['beauftragung']['done']);
|
||||
$this->assertSame(77, $process_state['beauftragung']['by']);
|
||||
$this->assertNotEmpty($process_state['beauftragung']['at']);
|
||||
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
|
||||
|
||||
$tech_state = json_decode((string) $captured['tech_state_json'], true);
|
||||
$this->assertTrue($tech_state['tls']['done']);
|
||||
$this->assertSame('verified', $tech_state['tls']['note']);
|
||||
}
|
||||
|
||||
public function testSaveStateGatesInformationStepUntilRequiredFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => ['technical_contact' => 'Jane'], // deployment / system_info / external_systems missing
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
// Saved (values persisted) but the step is held open + a warning is returned.
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertFalse($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
|
||||
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
|
||||
$this->assertNotSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
|
||||
}
|
||||
|
||||
public function testSaveStateCompletesInformationStepWhenAllRequiredFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => [
|
||||
'technical_contact' => 'Jane',
|
||||
'deployment' => 'Cloud',
|
||||
'system_info' => 'PHP 8.5',
|
||||
'external_systems' => 'https://api.example.test',
|
||||
// special_notes intentionally left empty — it is optional
|
||||
],
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayNotHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertTrue($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
|
||||
$this->assertSame(77, $process_state[SecurityCheckProcess::STEP_INFORMATION]['by']);
|
||||
}
|
||||
|
||||
public function testSaveStateGatesDatetimeStepUntilBothFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
// Scheduling needs both datetimes; with only one filled it stays open.
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => ['scheduling' => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => ['golive_start' => '2026-07-01T09:00'], // golive_end missing
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertFalse($process_state['scheduling']['done']);
|
||||
$this->assertSame('2026-07-01T09:00', $process_state['scheduling']['fields']['golive_start']);
|
||||
}
|
||||
|
||||
public function testSaveStateRejectsArchivedCheck(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_ARCHIVED,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
$repo->expects($this->never())->method('updateState');
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, ['step' => [], 'tech' => []], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('general', $result['errors']);
|
||||
}
|
||||
|
||||
public function testSetArchivedUpdatesStatusToArchived(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$capturedStatus = null;
|
||||
$repo->method('updateStatus')->willReturnCallback(function (int $tenantId, int $id, string $status) use (&$capturedStatus) {
|
||||
$capturedStatus = $status;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->setArchived(self::TENANT_ID, 1, true, 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $capturedStatus);
|
||||
}
|
||||
|
||||
public function testListPagedDelegatesToRepository(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('listPaged')->willReturn(['total' => 3, 'rows' => [['id' => 1]]]);
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
$result = $service->listPaged(self::TENANT_ID, ['search' => 'x']);
|
||||
|
||||
$this->assertSame(3, $result['total']);
|
||||
$this->assertCount(1, $result['rows']);
|
||||
}
|
||||
|
||||
public function testListAssignableUsersDelegatesToRepository(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('listTenantUsers')->willReturn([
|
||||
['id' => 3, 'display_name' => 'Alice'],
|
||||
['id' => 7, 'display_name' => 'Bob'],
|
||||
]);
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
$users = $service->listAssignableUsers(self::TENANT_ID);
|
||||
|
||||
$this->assertCount(2, $users);
|
||||
$this->assertSame('Alice', $users[0]['display_name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckTemplateServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
|
||||
public function testCreateRejectsEmptyCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->expects($this->never())->method('insert');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, '', 'Name', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateRejectsInvalidCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'has spaces!', 'Name', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateRejectsDuplicateCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('codeExists')->willReturn(true);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateSucceeds(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('codeExists')->willReturn(false);
|
||||
$repo->method('insert')->willReturn(12);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(12, $result['id']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsMissingTemplate(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(null);
|
||||
$repo->expects($this->never())->method('updateSchema');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 99, [['type' => 'check', 'label' => 'X']], 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaSlugifiesKeysBumpsVersionAndKeepsSections(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 3]);
|
||||
|
||||
$capturedJson = null;
|
||||
$capturedVersion = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json, int $version) use (&$capturedJson, &$capturedVersion) {
|
||||
$capturedJson = $json;
|
||||
$capturedVersion = $version;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'section', 'label' => 'Intranet'],
|
||||
['type' => 'check', 'label' => 'TLS überall erzwungen', 'hint' => 'check certs'],
|
||||
], 1);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $capturedVersion);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame(4, $data['version']);
|
||||
$this->assertSame('section', $data['items'][0]['type']);
|
||||
$this->assertSame('check', $data['items'][1]['type']);
|
||||
$this->assertSame('tls_ueberall_erzwungen', $data['items'][1]['key']);
|
||||
$this->assertSame('check certs', $data['items'][1]['hint']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaPersistsMarkdownAndOmitsEmpty(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$capturedJson = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
|
||||
$capturedJson = $json;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'check', 'label' => 'TLS', 'markdown' => '**Verify** the certificate chain.'],
|
||||
['type' => 'check', 'label' => 'Backup', 'markdown' => ' '],
|
||||
], 1);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame('**Verify** the certificate chain.', $data['items'][0]['markdown']);
|
||||
$this->assertArrayNotHasKey('markdown', $data['items'][1]);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaDeduplicatesKeys(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$capturedJson = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
|
||||
$capturedJson = $json;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'check', 'label' => 'Backup'],
|
||||
['type' => 'check', 'label' => 'Backup'],
|
||||
], 1);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame('backup', $data['items'][0]['key']);
|
||||
$this->assertSame('backup_2', $data['items'][1]['key']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsItemWithoutLabel(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
$repo->expects($this->never())->method('updateSchema');
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, [['type' => 'check', 'label' => '']], 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('schema', $result['errors']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsTooManyItems(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$items = [];
|
||||
for ($i = 0; $i <= SecurityCheckTemplateService::MAX_ITEMS; $i++) {
|
||||
$items[] = ['type' => 'check', 'label' => 'Item ' . $i];
|
||||
}
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, $items, 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('schema', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateMetaRejectsEmptyName(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->expects($this->never())->method('updateMeta');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->updateMeta(self::TENANT_ID, 1, '', true, 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_name', $result['errors']);
|
||||
}
|
||||
|
||||
public function testDecodeSchemaReturnsItems(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$decoded = $service->decodeSchema([
|
||||
'version' => 5,
|
||||
'tech_schema_json' => json_encode(['version' => 5, 'items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]]),
|
||||
]);
|
||||
|
||||
$this->assertSame(5, $decoded['version']);
|
||||
$this->assertCount(1, $decoded['items']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityMarkdownGatewayTest extends TestCase
|
||||
{
|
||||
public function testRendersBasicMarkdownToHtml(): void
|
||||
{
|
||||
$html = (new SecurityMarkdownGateway())->toHtml("**bold** text\n\n- one\n- two");
|
||||
|
||||
$this->assertStringContainsString('<strong>bold</strong>', $html);
|
||||
$this->assertStringContainsString('<li>one</li>', $html);
|
||||
$this->assertStringContainsString('<li>two</li>', $html);
|
||||
}
|
||||
|
||||
public function testEscapesRawHtmlSoItCannotInjectMarkup(): void
|
||||
{
|
||||
$html = (new SecurityMarkdownGateway())->toHtml('<script>alert(1)</script>');
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $html);
|
||||
$this->assertStringContainsString('<script>', $html);
|
||||
}
|
||||
|
||||
public function testEmptyOrWhitespaceInputReturnsEmptyString(): void
|
||||
{
|
||||
$gateway = new SecurityMarkdownGateway();
|
||||
|
||||
$this->assertSame('', $gateway->toHtml(''));
|
||||
$this->assertSame('', $gateway->toHtml(" \n "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityOAuthTokenServiceTest extends TestCase
|
||||
{
|
||||
public function testReturnsNullWhenAuthModeIsBasic(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
$repo->expects($this->never())->method('findValidToken');
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertNull($service->getAccessToken(1));
|
||||
}
|
||||
|
||||
public function testReturnsNullForInvalidTenant(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertNull($service->getAccessToken(0));
|
||||
}
|
||||
|
||||
public function testReturnsCachedTokenWhenAvailable(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
$repo->method('findValidToken')->willReturn('cached-bearer-token');
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertSame('cached-bearer-token', $service->getAccessToken(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityReportPdfServiceTest extends TestCase
|
||||
{
|
||||
private function makeService(): SecurityReportPdfService
|
||||
{
|
||||
$branding = $this->createMock(BrandingLogoService::class);
|
||||
$branding->method('hasLogo')->willReturn(false);
|
||||
|
||||
// Null tenant-logo service: render falls back to the static brand asset.
|
||||
return new SecurityReportPdfService(new SecurityCheckProcess(), $branding, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichedCheck(): array
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
|
||||
}
|
||||
$snapshot = ['version' => 1, 'items' => [
|
||||
['type' => 'section', 'label' => 'Transport'],
|
||||
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
|
||||
['type' => 'check', 'key' => 'hsts', 'label' => 'HSTS header'],
|
||||
['type' => 'section', 'label' => 'Application'],
|
||||
['type' => 'check', 'key' => 'csrf', 'label' => 'CSRF protection'],
|
||||
]];
|
||||
$techState = [
|
||||
'tls' => ['done' => true, 'note' => 'TLS 1.3'],
|
||||
'hsts' => ['done' => true, 'note' => ''],
|
||||
'csrf' => ['done' => false, 'note' => 'pending review'],
|
||||
];
|
||||
|
||||
return [
|
||||
'debitor_name' => 'Acme GmbH',
|
||||
'debitor_no' => 'D-100',
|
||||
'domain_url' => 'acme.de',
|
||||
'domain_no' => 'DNS-1',
|
||||
'product_name' => 'Intranet',
|
||||
'product_code' => 'intranet',
|
||||
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
||||
'process_state' => $processState,
|
||||
'tech_schema_snapshot' => $snapshot,
|
||||
'tech_state' => $techState,
|
||||
'progress' => $process->computeProgress($processState, $snapshot, $techState),
|
||||
];
|
||||
}
|
||||
|
||||
public function testBuildReportContextMapsCheckDataIntoRenderShape(): void
|
||||
{
|
||||
$context = $this->makeService()->buildReportContext($this->enrichedCheck());
|
||||
|
||||
$this->assertSame('Acme GmbH', $context['customer_name']);
|
||||
$this->assertSame('D-100', $context['customer_no']);
|
||||
$this->assertSame('acme.de', $context['domain']);
|
||||
$this->assertSame('Intranet', $context['product']);
|
||||
$this->assertNotSame('', $context['title']);
|
||||
|
||||
// Summary mirrors the progress math (3 tech items, 2 done).
|
||||
$this->assertSame(3, $context['summary']['tech_total']);
|
||||
$this->assertSame(2, $context['summary']['tech_done']);
|
||||
|
||||
// Five manual milestones, numbered with the technical step skipped.
|
||||
$this->assertCount(5, $context['steps']);
|
||||
$this->assertSame(1, $context['steps'][0]['number']);
|
||||
$this->assertTrue($context['steps'][0]['done']);
|
||||
$this->assertSame('2026-06-18 10:00:00', $context['steps'][0]['completed_at']);
|
||||
// 'report' is step 6 (the tech checklist step 5 is skipped from the milestone list).
|
||||
$this->assertSame(6, $context['steps'][4]['number']);
|
||||
|
||||
// Tech items grouped under their sections, carrying state + notes.
|
||||
$this->assertCount(2, $context['tech_sections']);
|
||||
$this->assertSame('Transport', $context['tech_sections'][0]['label']);
|
||||
$this->assertCount(2, $context['tech_sections'][0]['items']);
|
||||
$this->assertSame('TLS enforced', $context['tech_sections'][0]['items'][0]['label']);
|
||||
$this->assertTrue($context['tech_sections'][0]['items'][0]['done']);
|
||||
$this->assertSame('TLS 1.3', $context['tech_sections'][0]['items'][0]['note']);
|
||||
$this->assertSame('Application', $context['tech_sections'][1]['label']);
|
||||
$this->assertFalse($context['tech_sections'][1]['items'][0]['done']);
|
||||
}
|
||||
|
||||
public function testBuildReportContextHandlesEmptyCheckSafely(): void
|
||||
{
|
||||
$context = $this->makeService()->buildReportContext([]);
|
||||
|
||||
$this->assertSame('', $context['customer_name']);
|
||||
$this->assertSame('', $context['domain']);
|
||||
$this->assertSame([], $context['tech_sections']);
|
||||
$this->assertSame(0, $context['summary']['total']);
|
||||
$this->assertSame(0, $context['summary']['percent']);
|
||||
// The fixed process always yields its five manual milestones, all open.
|
||||
$this->assertCount(5, $context['steps']);
|
||||
$this->assertFalse($context['steps'][0]['done']);
|
||||
}
|
||||
|
||||
public function testBuildReportFilenameSlugifiesCustomerAndDomain(): void
|
||||
{
|
||||
$name = $this->makeService()->buildReportFilename($this->enrichedCheck());
|
||||
|
||||
$this->assertSame('security-check-report-acme-gmbh-acme-de.pdf', $name);
|
||||
}
|
||||
|
||||
public function testBuildReportFilenameFallsBackWhenNoIdentifiers(): void
|
||||
{
|
||||
$name = $this->makeService()->buildReportFilename([]);
|
||||
|
||||
$this->assertSame('security-check-report.pdf', $name);
|
||||
}
|
||||
|
||||
public function testRenderCustomerReportPdfProducesPdfBytes(): void
|
||||
{
|
||||
$pdf = $this->makeService()->renderCustomerReportPdf($this->enrichedCheck(), [
|
||||
'app_name' => 'CoreCore',
|
||||
]);
|
||||
|
||||
$this->assertNotSame('', $pdf);
|
||||
$this->assertStringStartsWith('%PDF-', $pdf);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user