From 498afc7840f298150303b7e5e8c6cbc1b260f183 Mon Sep 17 00:00:00 2001 From: Tobias Huttinger Date: Mon, 22 Jun 2026 13:19:05 +0200 Subject: [PATCH 1/4] First version of the security module --- .claude/settings.local.json | 3 +- composer.json | 6 +- config/modules.php | 2 +- modules/security/CLAUDE.md | 112 ++++ modules/security/i18n/default_de.json | 176 ++++++ modules/security/i18n/default_en.json | 176 ++++++ .../Providers/SecurityLayoutProvider.php | 43 ++ .../Repository/SecurityCheckRepository.php | 215 +++++++ .../SecurityCheckTemplateRepository.php | 213 +++++++ .../Repository/SecurityTokenRepository.php | 80 +++ .../Security/SecurityAuthorizationPolicy.php | 78 +++ .../Security/SecurityContainerRegistrar.php | 80 +++ .../Security/Service/SecurityBcGateway.php | 308 ++++++++++ .../Service/SecurityBcSettingsGateway.php | 275 +++++++++ .../Security/Service/SecurityCheckProcess.php | 270 +++++++++ .../Security/Service/SecurityCheckService.php | 296 ++++++++++ .../Service/SecurityCheckTemplateService.php | 223 +++++++ .../Service/SecurityMarkdownGateway.php | 34 ++ .../Service/SecurityOAuthTokenService.php | 175 ++++++ .../Service/SecurityReportPdfService.php | 282 +++++++++ .../001_create_security_check_templates.sql | 19 + .../migrations/002_create_security_checks.sql | 29 + .../003_create_security_oauth_token_cache.sql | 13 + modules/security/module.php | 162 ++++++ .../security/pages/security/checks-data().php | 47 ++ .../pages/security/checks/create().php | 78 +++ .../security/checks/create(default).phtml | 132 +++++ .../pages/security/checks/edit($id).php | 166 ++++++ .../pages/security/checks/edit(default).phtml | 257 ++++++++ .../pages/security/checks/filter-schema.php | 48 ++ .../pages/security/checks/index().php | 53 ++ .../security/checks/index(default).phtml | 65 +++ .../pages/security/checks/report($id).php | 82 +++ .../security/customer-domains-data().php | 47 ++ .../pages/security/customer-search-data().php | 48 ++ .../pages/security/settings/index().php | 89 +++ .../security/settings/index(default).phtml | 112 ++++ .../settings/test-connection-data().php | 24 + .../pages/security/templates-data().php | 41 ++ .../pages/security/templates/create().php | 62 ++ .../security/templates/create(default).phtml | 50 ++ .../pages/security/templates/edit($id).php | 93 +++ .../security/templates/edit(default).phtml | 74 +++ .../security/templates/filter-schema.php | 41 ++ .../pages/security/templates/index().php | 41 ++ .../security/templates/index(default).phtml | 48 ++ .../templates/aside-security-panel.phtml | 82 +++ .../templates/pdf/customer-report.phtml | 123 ++++ .../security/templates/tech-markdown.phtml | 21 + .../Service/SecurityBcGatewayTest.php | 54 ++ .../Service/SecurityBcSettingsGatewayTest.php | 86 +++ .../Service/SecurityCheckProcessTest.php | 218 +++++++ .../Service/SecurityCheckServiceTest.php | 344 +++++++++++ .../SecurityCheckTemplateServiceTest.php | 205 +++++++ .../Service/SecurityMarkdownGatewayTest.php | 34 ++ .../Service/SecurityOAuthTokenServiceTest.php | 46 ++ .../Service/SecurityReportPdfServiceTest.php | 129 +++++ modules/security/web/css/security.css | 547 ++++++++++++++++++ .../web/js/pages/security-checks-index.js | 80 +++ .../web/js/pages/security-templates-index.js | 79 +++ .../web/js/security-check-workspace.js | 108 ++++ .../web/js/security-customer-domain-select.js | 194 +++++++ modules/security/web/js/security-settings.js | 80 +++ .../web/js/security-template-schema-editor.js | 187 ++++++ 64 files changed, 7581 insertions(+), 4 deletions(-) create mode 100644 modules/security/CLAUDE.md create mode 100644 modules/security/i18n/default_de.json create mode 100644 modules/security/i18n/default_en.json create mode 100644 modules/security/lib/Module/Security/Providers/SecurityLayoutProvider.php create mode 100644 modules/security/lib/Module/Security/Repository/SecurityCheckRepository.php create mode 100644 modules/security/lib/Module/Security/Repository/SecurityCheckTemplateRepository.php create mode 100644 modules/security/lib/Module/Security/Repository/SecurityTokenRepository.php create mode 100644 modules/security/lib/Module/Security/SecurityAuthorizationPolicy.php create mode 100644 modules/security/lib/Module/Security/SecurityContainerRegistrar.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityBcGateway.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityBcSettingsGateway.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityCheckProcess.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityCheckService.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityCheckTemplateService.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityMarkdownGateway.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityOAuthTokenService.php create mode 100644 modules/security/lib/Module/Security/Service/SecurityReportPdfService.php create mode 100644 modules/security/migrations/001_create_security_check_templates.sql create mode 100644 modules/security/migrations/002_create_security_checks.sql create mode 100644 modules/security/migrations/003_create_security_oauth_token_cache.sql create mode 100644 modules/security/module.php create mode 100644 modules/security/pages/security/checks-data().php create mode 100644 modules/security/pages/security/checks/create().php create mode 100644 modules/security/pages/security/checks/create(default).phtml create mode 100644 modules/security/pages/security/checks/edit($id).php create mode 100644 modules/security/pages/security/checks/edit(default).phtml create mode 100644 modules/security/pages/security/checks/filter-schema.php create mode 100644 modules/security/pages/security/checks/index().php create mode 100644 modules/security/pages/security/checks/index(default).phtml create mode 100644 modules/security/pages/security/checks/report($id).php create mode 100644 modules/security/pages/security/customer-domains-data().php create mode 100644 modules/security/pages/security/customer-search-data().php create mode 100644 modules/security/pages/security/settings/index().php create mode 100644 modules/security/pages/security/settings/index(default).phtml create mode 100644 modules/security/pages/security/settings/test-connection-data().php create mode 100644 modules/security/pages/security/templates-data().php create mode 100644 modules/security/pages/security/templates/create().php create mode 100644 modules/security/pages/security/templates/create(default).phtml create mode 100644 modules/security/pages/security/templates/edit($id).php create mode 100644 modules/security/pages/security/templates/edit(default).phtml create mode 100644 modules/security/pages/security/templates/filter-schema.php create mode 100644 modules/security/pages/security/templates/index().php create mode 100644 modules/security/pages/security/templates/index(default).phtml create mode 100644 modules/security/templates/aside-security-panel.phtml create mode 100644 modules/security/templates/pdf/customer-report.phtml create mode 100644 modules/security/templates/tech-markdown.phtml create mode 100644 modules/security/tests/Module/Security/Service/SecurityBcGatewayTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityBcSettingsGatewayTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityCheckProcessTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityCheckServiceTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityCheckTemplateServiceTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityMarkdownGatewayTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityOAuthTokenServiceTest.php create mode 100644 modules/security/tests/Module/Security/Service/SecurityReportPdfServiceTest.php create mode 100644 modules/security/web/css/security.css create mode 100644 modules/security/web/js/pages/security-checks-index.js create mode 100644 modules/security/web/js/pages/security-templates-index.js create mode 100644 modules/security/web/js/security-check-workspace.js create mode 100644 modules/security/web/js/security-customer-domain-select.js create mode 100644 modules/security/web/js/security-settings.js create mode 100644 modules/security/web/js/security-template-schema-editor.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b5be4ee..c9addf6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(Remove-Item -Force \"modules/helpdesk/web/js/helpdesk-dashboard.js\")", "Bash(git checkout *)", "Bash(git add *)", - "Bash(git commit -m ' *)" + "Bash(git commit -m ' *)", + "Bash(docker compose *)" ] } } diff --git a/composer.json b/composer.json index a94ecde..f8abd47 100644 --- a/composer.json +++ b/composer.json @@ -31,14 +31,16 @@ "psr-4": { "MintyPHP\\Tests\\": "tests/", "MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/", - "MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/" + "MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/", + "MintyPHP\\Tests\\Module\\Security\\": "modules/security/tests/Module/Security/" } }, "autoload": { "psr-4": { "MintyPHP\\": "core/", "MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/", - "MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/" + "MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/", + "MintyPHP\\Module\\Security\\": "modules/security/lib/Module/Security/" } }, "scripts": { diff --git a/config/modules.php b/config/modules.php index a81342a..b24c189 100644 --- a/config/modules.php +++ b/config/modules.php @@ -12,5 +12,5 @@ * Each entry must match a directory name under modules/ containing a module.php manifest. */ return [ - 'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk'], + 'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk', 'security'], ]; diff --git a/modules/security/CLAUDE.md b/modules/security/CLAUDE.md new file mode 100644 index 0000000..d815f1e --- /dev/null +++ b/modules/security/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md — Security Module + +Self-contained MintyPHP module. Namespace: `MintyPHP\Module\Security\*`. **Standalone** — +`requires: []`, no dependency on helpdesk. All changes stay inside `modules/security/`. + +After any manifest change (route, permission, slot): `docker compose exec php php bin/console module:sync` + +--- + +## What it does + +Lets the security officer run **security checks** for customers. A check is created via a wizard +(select customer → domain → software product), then tracked on a checklist workspace that combines: + +1. A **fixed 7-step internal process** (`SecurityCheckProcess`, identical for every check), and +2. A **per-product technical checklist** defined as a reusable **template** (the product catalog is + owned by this module — the templates *are* the product list). + +Overall status (`open` → `in_progress` → `completed`, plus manual `archived`) is **derived** from +checklist progress. Each step/item records who completed it and when (completion log). + +--- + +## Architecture + +``` +lib/Module/Security/ + SecurityAuthorizationPolicy.php — ability/permission constants + authorize() + SecurityContainerRegistrar.php — all DI bindings (add new services here) + Providers/SecurityLayoutProvider.php — resolves can_* flags for the sidebar + Repository/ — SQL only (tenant-scoped, prepared statements) + Service/ + SecurityCheckProcess.php — fixed 7-step process + progress/status math (pure, no DI) + SecurityCheckService.php — check CRUD, saveState, status derivation, archive + SecurityCheckTemplateService.php— template CRUD + tech-schema validation/slugify + SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted) + SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache + SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer) + SecurityReportPdfService.php — customer PDF report (Dompdf); pure context builder + render +pages/security/ — actions (*.php) + views (*.phtml) + checks/report($id).php — streams the customer report PDF (gated on steps 1–5 complete) +templates/aside-security-panel.phtml— sidebar navigation +templates/pdf/customer-report.phtml — customer report PDF template (hydrated by SecurityReportPdfService) +i18n/default_de.json + default_en.json — keep both in sync (identical key sets) +migrations/ — 003 SQL files (module:migrate) +web/css/security.css | web/js/ — module styles + runtime components + page scripts +tests/Module/Security/Service/ — 7 test files (happy path + edge case) +``` + +--- + +## Permissions (6) + +All constants in `SecurityAuthorizationPolicy`: + +| Constant | Key | +|---|---| +| `ABILITY_ACCESS` | `security.access` — gates the main-menu entry | +| `ABILITY_CHECKS_VIEW` | `security.checks.view` | +| `ABILITY_CHECKS_CREATE` | `security.checks.create` — create + edit the checklist | +| `ABILITY_CHECKS_MANAGE` | `security.checks.manage` — archive, delete | +| `ABILITY_TEMPLATES_MANAGE` | `security.templates.manage` | +| `ABILITY_SETTINGS_MANAGE` | `security.settings.manage` | + +Gate every action with `Guard::requireAbilityOrForbidden(...)`; tenant scope enforced in repositories. + +--- + +## DB schema + +| Table | Key columns | +|---|---| +| `security_check_templates` | tenant_id, product_code (uniq), product_name, tech_schema_json, active, version | +| `security_checks` | tenant_id, debitor_no, domain_no, product_code, template_id, owner_user_id, status, process_state_json, tech_schema_snapshot_json, tech_state_json | +| `security_oauth_token_cache` | tenant_id, access_token_encrypted, expires_at | + +`tech_schema_snapshot_json` freezes the template's checklist onto the check at creation time, so later +template edits never mutate in-flight checks. JSON state shapes: +- tech schema: `{version, items:[ {type:"section",label}, {type:"check",key,label,hint?} ]}` +- process_state: `{: {done, by, at, note, fields?}}` +- tech_state: `{: {done, by, at, note}}` + +--- + +## Routes / pages + +Main menu entry via the `aside.tab_panel` slot (icon `bi-shield-check`, permission `security.access`). +Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}` +(workspace), `security/checks/report/{id}` (streams the customer PDF report), +`security/templates` (+create/edit/{id}), `security/settings`, plus data endpoints +`security/checks-data`, `security/templates-data`, `security/customer-search-data`, +`security/customer-domains-data`, `security/settings/test-connection-data`. + +## JS runtime components + +`data-app-component` on the container: `security-customer-domain-select` (wizard domain picker), +`security-check-workspace` (live progress), `security-template-schema-editor` (checklist editor), +`security-settings` (auth-mode toggle + connection test). List pages load their own +`web/js/pages/*-index.js` via ` + + diff --git a/modules/security/pages/security/checks/report($id).php b/modules/security/pages/security/checks/report($id).php new file mode 100644 index 0000000..4c844c7 --- /dev/null +++ b/modules/security/pages/security/checks/report($id).php @@ -0,0 +1,82 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + +$service = app(SecurityCheckService::class); +$process = app(SecurityCheckProcess::class); +$check = $service->findById($tenantId, $checkId); + +if ($check === null) { + Flash::error(t('Security check not found'), $closeTarget, 'security_check_not_found'); + Router::redirect($closeTarget); + + return; +} + +// Re-enforce the gate server-side: the report can only be produced once the +// check has actually been carried out (all preceding steps complete). The UI +// hides the button, but it must not be the only line of defence. +$prerequisitesMet = $process->reportPrerequisitesMet( + is_array($check['process_state'] ?? null) ? $check['process_state'] : [], + is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [], + is_array($check['tech_state'] ?? null) ? $check['tech_state'] : [] +); +if (!$prerequisitesMet) { + Flash::error(t('Complete all previous steps before generating the customer report.'), $editTarget, 'security_report_gated'); + Router::redirect($editTarget); + + return; +} + +$pdfService = app(SecurityReportPdfService::class); +$pdf = $pdfService->renderCustomerReportPdf($check, [ + 'locale' => strtolower((string) (I18n::$locale ?? I18n::$defaultLocale)), + 'app_name' => appTitle(), + 'tenant_uuid' => (string) ($session['current_tenant']['uuid'] ?? ''), +]); + +if ($pdf === '') { + // Concrete renderer/template failures are handled inside the service. + http_response_code(500); + + return; +} + +$filename = $pdfService->buildReportFilename($check); + +header('Content-Type: application/pdf'); +header('Content-Disposition: inline; filename="' . $filename . '"'); +header('Cache-Control: private, no-store, max-age=0'); +header('Pragma: no-cache'); +header('X-Content-Type-Options: nosniff'); +header('Content-Length: ' . strlen($pdf)); + +$output = fopen('php://output', 'wb'); +if ($output === false) { + http_response_code(500); + + return; +} +fwrite($output, $pdf); +fclose($output); diff --git a/modules/security/pages/security/customer-domains-data().php b/modules/security/pages/security/customer-domains-data().php new file mode 100644 index 0000000..a8878b9 --- /dev/null +++ b/modules/security/pages/security/customer-domains-data().php @@ -0,0 +1,47 @@ +query('debitor_no', '')); + +if ($debitorNo === '') { + Router::json([]); + + return; +} + +try { + $rows = app(SecurityBcGateway::class)->listDomainsForCustomer($debitorNo); +} catch (\Throwable) { + http_response_code(502); + Router::json(['error' => 'lookup_failed']); + + return; +} + +$items = []; +foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $no = trim((string) ($row['No'] ?? '')); + $url = trim((string) ($row['URL'] ?? '')); + if ($no === '') { + continue; + } + $items[] = [ + 'value' => $no, + 'label' => $url !== '' ? $url : $no, + 'url' => $url, + ]; +} + +Router::json($items); diff --git a/modules/security/pages/security/customer-search-data().php b/modules/security/pages/security/customer-search-data().php new file mode 100644 index 0000000..3070b71 --- /dev/null +++ b/modules/security/pages/security/customer-search-data().php @@ -0,0 +1,48 @@ +query('q', '')); + +if ($query === '' || mb_strlen($query) < 2) { + Router::json([]); + + return; +} + +try { + $rows = app(SecurityBcGateway::class)->searchCustomers($query); +} catch (\Throwable) { + http_response_code(502); + Router::json(['error' => 'search_failed']); + + return; +} + +$items = []; +foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $no = trim((string) ($row['No'] ?? '')); + $name = trim((string) ($row['Name'] ?? '')); + if ($no === '') { + continue; + } + $items[] = [ + 'value' => $no, + 'label' => $no . ' — ' . $name, + 'name' => $name, + 'meta' => trim((string) ($row['City'] ?? '')), + ]; +} + +Router::json($items); diff --git a/modules/security/pages/security/settings/index().php b/modules/security/pages/security/settings/index().php new file mode 100644 index 0000000..fdaf7de --- /dev/null +++ b/modules/security/pages/security/settings/index().php @@ -0,0 +1,89 @@ +isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'security/settings', 'csrf_expired'); + Router::redirect('security/settings'); + + return; +} + +if ($request->isMethod('POST')) { + $post = $request->bodyAll(); + $authMode = trim((string) ($post['auth_mode'] ?? SecurityBcSettingsGateway::AUTH_MODE_BASIC)); + $odataBaseUrl = trim((string) ($post['odata_base_url'] ?? '')); + $companyName = trim((string) ($post['company_name'] ?? '')); + $basicUser = trim((string) ($post['basic_user'] ?? '')); + $basicPassword = trim((string) ($post['basic_password'] ?? '')); + $oauthTenantId = trim((string) ($post['oauth_tenant_id'] ?? '')); + $oauthClientId = trim((string) ($post['oauth_client_id'] ?? '')); + $oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? '')); + $oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? '')); + + $errorBag = formErrors(); + if ($odataBaseUrl !== '' && !preg_match('#^https://#i', $odataBaseUrl)) { + $errorBag->add('odata_base_url', t('OData Base URL must use HTTPS')); + } + if ($oauthTokenEndpoint !== '' && !preg_match('#^https://#i', $oauthTokenEndpoint)) { + $errorBag->add('oauth_token_endpoint', t('Token endpoint must use HTTPS')); + } + + if (!$errorBag->hasAny()) { + $settingsGateway->setAuthMode($authMode); + $settingsGateway->setODataBaseUrl($odataBaseUrl !== '' ? $odataBaseUrl : null); + $settingsGateway->setCompanyName($companyName !== '' ? $companyName : null); + $settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null); + if ($basicPassword !== '') { + $settingsGateway->setBasicPassword($basicPassword); + } + $settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null); + $settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null); + if ($oauthClientSecret !== '') { + $settingsGateway->setOAuthClientSecret($oauthClientSecret); + } + $settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint !== '' ? $oauthTokenEndpoint : null); + + $tokenRepository->deleteAll(); + Flash::success(t('Settings saved'), 'security/settings', 'settings_saved'); + } else { + flashFormErrors($errorBag, 'security/settings', 'settings_error'); + } + + Router::redirect('security/settings'); + + return; +} + +$authMode = $settingsGateway->getAuthMode(); +$odataBaseUrl = $settingsGateway->getODataBaseUrl(); +$companyName = $settingsGateway->getCompanyName(); +$basicUser = $settingsGateway->getBasicUser() ?? ''; +$hasBasicPassword = $settingsGateway->getBasicPassword() !== null; +$oauthTenantId = $settingsGateway->getOAuthTenantId() ?? ''; +$oauthClientId = $settingsGateway->getOAuthClientId() ?? ''; +$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null; +$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? ''; +$configErrors = $settingsGateway->validateConfiguration(); + +Buffer::set('title', t('Security settings')); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Settings')], +]; diff --git a/modules/security/pages/security/settings/index(default).phtml b/modules/security/pages/security/settings/index(default).phtml new file mode 100644 index 0000000..dba7e2c --- /dev/null +++ b/modules/security/pages/security/settings/index(default).phtml @@ -0,0 +1,112 @@ + +
+
+ t('Security settings'), + 'actions' => [ + ['label' => t('Save'), 'type' => 'submit', 'form' => 'security-settings-form', 'class' => 'primary', 'tone' => 'success'], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + +
+

+
    +
  • +
+
+ + +
+
+

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
hidden> +

+
+ + +
+
+ + +
+
+ +
hidden> +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+

+ + +
+
+
diff --git a/modules/security/pages/security/settings/test-connection-data().php b/modules/security/pages/security/settings/test-connection-data().php new file mode 100644 index 0000000..b2e66c7 --- /dev/null +++ b/modules/security/pages/security/settings/test-connection-data().php @@ -0,0 +1,24 @@ +testConnection(); + +if (($result['ok'] ?? false) === true) { + Router::json(['ok' => true, 'http_code' => (int) ($result['http_code'] ?? 200)]); + + return; +} + +http_response_code(200); +Router::json([ + 'ok' => false, + 'error' => (string) ($result['error'] ?? 'Connection failed'), +]); diff --git a/modules/security/pages/security/templates-data().php b/modules/security/pages/security/templates-data().php new file mode 100644 index 0000000..6cf246c --- /dev/null +++ b/modules/security/pages/security/templates-data().php @@ -0,0 +1,41 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + +$result = app(SecurityCheckTemplateService::class)->listPaged($tenantId, $filters); +$rows = $result['rows'] ?? []; +$total = (int) ($result['total'] ?? 0); + +$editBaseUrl = lurl('security/templates/edit/'); + +$preparedRows = []; +foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + $items = SecurityCheckProcess::techCheckItems( + is_array(json_decode((string) ($row['tech_schema_json'] ?? ''), true)) ? json_decode((string) ($row['tech_schema_json'] ?? ''), true) : [] + ); + $preparedRows[] = [ + 'id' => $id, + 'product_code' => (string) ($row['product_code'] ?? ''), + 'product_name' => (string) ($row['product_name'] ?? ''), + 'item_count' => count($items), + 'active' => (int) ($row['active'] ?? 0) === 1, + 'updated_at' => (string) ($row['updated_at'] ?? ($row['created_at'] ?? '')), + 'edit_url' => $id > 0 ? $editBaseUrl . $id : '', + ]; +} + +gridJsonDataResult($preparedRows, $total); diff --git a/modules/security/pages/security/templates/create().php b/modules/security/pages/security/templates/create().php new file mode 100644 index 0000000..283da24 --- /dev/null +++ b/modules/security/pages/security/templates/create().php @@ -0,0 +1,62 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); +$userId = (int) ($session['user']['id'] ?? 0); + +$service = app(SecurityCheckTemplateService::class); +$errorBag = formErrors(); + +$form = [ + 'product_code' => (string) $request->body('product_code', ''), + 'product_name' => (string) $request->body('product_name', ''), +]; + +if ($request->isMethod('POST')) { + if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired'); + Router::redirect($createTarget); + + return; + } + + $result = $service->create($tenantId, $form['product_code'], $form['product_name'], $userId); + if ($result['ok'] ?? false) { + $editUrl = lurl('security/templates/edit/' . (int) $result['id']); + Flash::success(t('Template created'), $editUrl, 'template_created'); + Router::redirect($editUrl); + + return; + } + + $errorBag->merge($result['errors'] ?? []); +} + +$validationSummaryErrors = $errorBag->toArray(); +$errors = $errorBag->toFlatList(); + +Buffer::set('title', t('New template')); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Check templates'), 'path' => 'security/templates'], + ['label' => t('New template')], +]; diff --git a/modules/security/pages/security/templates/create(default).phtml b/modules/security/pages/security/templates/create(default).phtml new file mode 100644 index 0000000..68e44b2 --- /dev/null +++ b/modules/security/pages/security/templates/create(default).phtml @@ -0,0 +1,50 @@ + +
+
+ + + +

+
+ + + + + +
+
+

+

+ +
+ + + +

+ +
+ +
+ + + +

+ +
+ + + +
+ + +
+
+
+
diff --git a/modules/security/pages/security/templates/edit($id).php b/modules/security/pages/security/templates/edit($id).php new file mode 100644 index 0000000..9865ae9 --- /dev/null +++ b/modules/security/pages/security/templates/edit($id).php @@ -0,0 +1,93 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); +$userId = (int) ($session['user']['id'] ?? 0); + +$service = app(SecurityCheckTemplateService::class); +$template = $service->findById($tenantId, $templateId); + +if ($template === null) { + Flash::error(t('Template not found'), $closeTarget, 'template_not_found'); + Router::redirect($closeTarget); + + return; +} + +$errorBag = formErrors(); + +if ($request->isMethod('POST')) { + if (!Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired'); + Router::redirect($editTarget); + + return; + } + + $name = (string) $request->body('product_name', ''); + $active = $request->body('active', '') !== ''; + + $metaResult = $service->updateMeta($tenantId, $templateId, $name, $active, $userId); + $errorBag->merge($metaResult['errors'] ?? []); + + $items = []; + $schemaJson = (string) $request->body('tech_schema_json', ''); + if ($schemaJson !== '') { + $decoded = json_decode($schemaJson, true); + if (is_array($decoded)) { + $items = $decoded; + } + } + $schemaResult = $service->saveTechSchema($tenantId, $templateId, $items, $userId); + $errorBag->merge($schemaResult['errors'] ?? []); + + if (!$errorBag->hasAny()) { + $action = (string) $request->body('action', 'save'); + $target = $action === 'save_close' ? $closeTarget : $editTarget; + Flash::success(t('Template saved'), $target, 'template_saved'); + Router::redirect($target); + + return; + } + + flashFormErrors($errorBag, $editTarget, 'template_save_failed'); + Router::redirect($editTarget); + + return; +} + +$schema = $service->decodeSchema($template); +$schemaItems = $schema['items'] ?? []; + +$validationSummaryErrors = $errorBag->toArray(); +$errors = $errorBag->toFlatList(); + +$titleText = trim((string) ($template['product_name'] ?? '')) !== '' + ? (string) $template['product_name'] + : t('Edit template'); +Buffer::set('title', $titleText); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Check templates'), 'path' => 'security/templates'], + ['label' => $titleText], +]; diff --git a/modules/security/pages/security/templates/edit(default).phtml b/modules/security/pages/security/templates/edit(default).phtml new file mode 100644 index 0000000..3ae9072 --- /dev/null +++ b/modules/security/pages/security/templates/edit(default).phtml @@ -0,0 +1,74 @@ + +
+
+ trim((string) ($template['product_name'] ?? '')) !== '' ? (string) $template['product_name'] : t('Edit template'), + 'backHref' => lurl($closeTarget), + 'actions' => [ + ['label' => t('Save'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save', 'class' => 'primary', 'tone' => 'success'], + ['label' => t('Save & close'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save_close', 'class' => 'secondary outline'], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + + +
+
+
+
+ + + +
+
+ + +

+
+
+ +
+ +
+

+

+

+ +
+
+ + +
+ + +
+
+
diff --git a/modules/security/pages/security/templates/filter-schema.php b/modules/security/pages/security/templates/filter-schema.php new file mode 100644 index 0000000..107372e --- /dev/null +++ b/modules/security/pages/security/templates/filter-schema.php @@ -0,0 +1,41 @@ + [ + 'search' => ['type' => 'string'], + 'active' => ['type' => 'string', 'default' => 'all'], + 'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100], + 'offset' => ['type' => 'int', 'default' => 0, 'min' => 0], + 'order' => [ + 'type' => 'order', + 'allowed' => ['id', 'product_code', 'product_name', 'active', 'version', 'updated_at'], + 'default' => 'product_name', + ], + 'dir' => ['type' => 'dir', 'default' => 'asc'], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'placeholder' => 'Search templates...', + 'input_id' => 'security-templates-search-input', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'active', + 'type' => 'select', + 'label' => 'Active', + 'input_id' => 'security-templates-active-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => '1', 'description' => 'Active'], + ['id' => '0', 'description' => 'Inactive'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], + ], +]); diff --git a/modules/security/pages/security/templates/index().php b/modules/security/pages/security/templates/index().php new file mode 100644 index 0000000..319b6ee --- /dev/null +++ b/modules/security/pages/security/templates/index().php @@ -0,0 +1,41 @@ +queryAll(); + +$filterSchema = require __DIR__ . '/filter-schema.php'; +$listFilterContext = gridBuildListFilterContext($filterSchema, [ + 'query' => $query, + 'search_keys' => ['search'], +]); +$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema']; +$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema']; +$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema']; +$toolbarFilterState = $listFilterContext['toolbarFilterState']; +$clientFilterSchema = $listFilterContext['clientFilterSchema']; +$searchConfig = $listFilterContext['searchConfig']; +$schemaByKey = $listFilterContext['schemaByKey']; +$filterChipMeta = [ + 'search' => ['label' => t('Search'), 'type' => 'text'], + 'active' => [ + 'label' => t('Active'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['active']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['active'] ?? [])), + ], +]; + +Buffer::set('title', t('Check templates')); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Check templates')], +]; diff --git a/modules/security/pages/security/templates/index(default).phtml b/modules/security/pages/security/templates/index(default).phtml new file mode 100644 index 0000000..cd00d9c --- /dev/null +++ b/modules/security/pages/security/templates/index(default).phtml @@ -0,0 +1,48 @@ + + + + + + + + +
+
+
+ + + + diff --git a/modules/security/templates/aside-security-panel.phtml b/modules/security/templates/aside-security-panel.phtml new file mode 100644 index 0000000..42d8c69 --- /dev/null +++ b/modules/security/templates/aside-security-panel.phtml @@ -0,0 +1,82 @@ + 'security-operations', + 'label' => t('Operations'), + 'icon' => 'bi-clipboard-check', + 'items' => [ + [ + 'label' => t('Security checks'), + 'path' => 'security/checks', + 'active' => $checksActive, + 'visible' => $canViewChecks, + ], + ], + ], + [ + 'key' => 'security-administration', + 'label' => t('Administration'), + 'icon' => 'bi-sliders', + 'items' => [ + [ + 'label' => t('Check templates'), + 'path' => 'security/templates', + 'active' => $templatesActive, + 'visible' => $canManageTemplates, + ], + [ + 'label' => t('Settings'), + 'path' => 'security/settings', + 'active' => $settingsActive, + 'visible' => $canManageSettings, + ], + ], + ], +]; +?> +
    + !empty($item['visible']))); + if (!$visibleItems) { + continue; + } + $groupIsActive = false; + foreach ($visibleItems as $item) { + if (!empty(($item['active'] ?? [])['isActive'])) { + $groupIsActive = true; + break; + } + } + ?> +
  • +
    open> + + + + +
      + '', 'aria' => '']; + ?> +
    • + > + + +
    • + +
    +
    +
  • + +
diff --git a/modules/security/templates/pdf/customer-report.phtml b/modules/security/templates/pdf/customer-report.phtml new file mode 100644 index 0000000..1f2ff92 --- /dev/null +++ b/modules/security/templates/pdf/customer-report.phtml @@ -0,0 +1,123 @@ + $report + */ +$report = is_array($report ?? null) ? $report : []; +$summary = is_array($report['summary'] ?? null) ? $report['summary'] : []; +$steps = is_array($report['steps'] ?? null) ? $report['steps'] : []; +$techSections = is_array($report['tech_sections'] ?? null) ? $report['tech_sections'] : []; + +$percent = (int) ($summary['percent'] ?? 0); +$techDone = (int) ($summary['tech_done'] ?? 0); +$techTotal = (int) ($summary['tech_total'] ?? 0); +$appName = trim((string) ($report['app_name'] ?? '')); +?> + + + + + <?php e((string) ($report['title'] ?? '')); ?> + + + +
+ +

+
+
+ + + + + + +
()
+ +

+
(%)
+
+ +

+ +

+ + + + + + + + + + + + + +
+ + + + + + + + +
+ +
+ + +

+ + + + + + + +
+ + + + + + + . + + · + +
+ + + + diff --git a/modules/security/templates/tech-markdown.phtml b/modules/security/templates/tech-markdown.phtml new file mode 100644 index 0000000..05f42f9 --- /dev/null +++ b/modules/security/templates/tech-markdown.phtml @@ -0,0 +1,21 @@ + toggle. + * + * Lives in a partial (not the routed view) so the framework Analyzer permits the + * raw echo. The HTML is produced by SecurityMarkdownGateway with html_input=escape + * and unsafe links dropped, so it is safe to output verbatim. + * + * @var string $markdownHtml + */ +$markdownHtml = (string) ($markdownHtml ?? ''); +if ($markdownHtml === '') { + return; +} +?> +
+ +
+
diff --git a/modules/security/tests/Module/Security/Service/SecurityBcGatewayTest.php b/modules/security/tests/Module/Security/Service/SecurityBcGatewayTest.php new file mode 100644 index 0000000..151e7bb --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityBcGatewayTest.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityBcSettingsGatewayTest.php b/modules/security/tests/Module/Security/Service/SecurityBcSettingsGatewayTest.php new file mode 100644 index 0000000..974db16 --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityBcSettingsGatewayTest.php @@ -0,0 +1,86 @@ + $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')); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityCheckProcessTest.php b/modules/security/tests/Module/Security/Service/SecurityCheckProcessTest.php new file mode 100644 index 0000000..ea47c52 --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityCheckProcessTest.php @@ -0,0 +1,218 @@ +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)); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityCheckServiceTest.php b/modules/security/tests/Module/Security/Service/SecurityCheckServiceTest.php new file mode 100644 index 0000000..81ad850 --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityCheckServiceTest.php @@ -0,0 +1,344 @@ +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']); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityCheckTemplateServiceTest.php b/modules/security/tests/Module/Security/Service/SecurityCheckTemplateServiceTest.php new file mode 100644 index 0000000..693931f --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityCheckTemplateServiceTest.php @@ -0,0 +1,205 @@ +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']); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityMarkdownGatewayTest.php b/modules/security/tests/Module/Security/Service/SecurityMarkdownGatewayTest.php new file mode 100644 index 0000000..8dad8ae --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityMarkdownGatewayTest.php @@ -0,0 +1,34 @@ +toHtml("**bold** text\n\n- one\n- two"); + + $this->assertStringContainsString('bold', $html); + $this->assertStringContainsString('
  • one
  • ', $html); + $this->assertStringContainsString('
  • two
  • ', $html); + } + + public function testEscapesRawHtmlSoItCannotInjectMarkup(): void + { + $html = (new SecurityMarkdownGateway())->toHtml(''); + + $this->assertStringNotContainsString('