First version of the security module

This commit is contained in:
2026-06-22 13:19:05 +02:00
parent 0392043ee3
commit 498afc7840
64 changed files with 7581 additions and 4 deletions

View File

@@ -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);
}
}

View File

@@ -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'));
}
}

View File

@@ -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));
}
}

View File

@@ -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']);
}
}

View File

@@ -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']);
}
}

View File

@@ -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('&lt;script&gt;', $html);
}
public function testEmptyOrWhitespaceInputReturnsEmptyString(): void
{
$gateway = new SecurityMarkdownGateway();
$this->assertSame('', $gateway->toHtml(''));
$this->assertSame('', $gateway->toHtml(" \n "));
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}