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..b4eb550 --- /dev/null +++ b/modules/security/CLAUDE.md @@ -0,0 +1,120 @@ +# 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); picks custom template or built-in PHTML default + SecurityReportSettingsGateway.php— report HTML/CSS template (core settings table, clear text) + SecurityReportTemplateService.php— {{var}} substitution into the admin template + default scaffold (pure) + SecurityReportLogoService.php — report logo upload/serve (storage/security/report-logo, ImageUploadTrait) +pages/security/ — actions (*.php) + views (*.phtml) + checks/report($id).php — streams the customer report PDF (gated on steps 1–5 complete) + report-design/ — report look config: logo upload + HTML/CSS template editor; logo-file() serves the logo (settings.manage) +templates/aside-security-panel.phtml— sidebar navigation +templates/pdf/customer-report.phtml — built-in default report layout (used when no custom template is stored) +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/ — 10 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`, `security/report-design` +(customer report look: logo + HTML/CSS template), plus data endpoints +`security/checks-data`, `security/templates-data`, `security/customer-search-data`, +`security/customer-domains-data`, `security/settings/test-connection-data`, +`security/report-design/logo-file` (serves the uploaded report logo). + +`report-design` reuses the `security.settings.manage` permission (no new permission key). + +## 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/report-design/index().php b/modules/security/pages/security/report-design/index().php new file mode 100644 index 0000000..b3e970d --- /dev/null +++ b/modules/security/pages/security/report-design/index().php @@ -0,0 +1,84 @@ +isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'security/report-design', 'csrf_expired'); + Router::redirect('security/report-design'); + + return; +} + +if ($request->isMethod('POST')) { + $action = (string) $request->body('action', 'save'); + + if ($action === 'delete_logo') { + $logoService->delete(); + Flash::success(t('Report logo removed'), 'security/report-design', 'report_logo_removed'); + Router::redirect('security/report-design'); + + return; + } + + $errorBag = formErrors(); + + if (!$reportSettings->setTemplateHtml((string) $request->body('template_html', ''))) { + $errorBag->add('template_html', t('The template is too large.')); + } + + // Only touch the logo when a file was actually submitted. + $upload = $request->filesAll()['logo'] ?? []; + if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $result = $logoService->saveUpload($upload); + if (!($result['ok'] ?? false)) { + $errorBag->addGlobal((string) ($result['error'] ?? t('Upload failed'))); + } + } + + if ($errorBag->hasAny()) { + flashFormErrors($errorBag, 'security/report-design', 'report_design_error'); + } else { + Flash::success(t('Settings saved'), 'security/report-design', 'report_design_saved'); + } + + Router::redirect('security/report-design'); + + return; +} + +$hasCustomTemplate = $reportSettings->hasCustomTemplate(); +// Prefill the editor with the built-in default scaffold when nothing is stored, +// so the admin has a working baseline to customise. +$templateHtml = $hasCustomTemplate ? $reportSettings->getTemplateHtml() : $templateService->defaultTemplateHtml(); +$variables = $templateService->availableVariables(); + +$hasLogo = $logoService->hasLogo(); +$logoVersion = ''; +if ($hasLogo) { + $logoPath = $logoService->findLogoPath(256); + $logoVersion = ($logoPath && is_file($logoPath)) ? (string) filemtime($logoPath) : ''; +} + +Buffer::set('title', t('Customer report design')); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Customer report design')], +]; diff --git a/modules/security/pages/security/report-design/index(default).phtml b/modules/security/pages/security/report-design/index(default).phtml new file mode 100644 index 0000000..0de7e49 --- /dev/null +++ b/modules/security/pages/security/report-design/index(default).phtml @@ -0,0 +1,75 @@ + +
+
+ t('Customer report design'), + 'actions' => [ + ['label' => t('Save'), 'type' => 'submit', 'form' => 'security-report-design-form', 'class' => 'primary', 'tone' => 'success'], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +

+ +
+

+ 'logo', + 'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp', + 'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.'), + 'currentSrc' => $logoCurrentSrc, + 'currentLabel' => t('Current logo'), + 'deleteConfirm' => t('Delete this logo?'), + 'formId' => 'security-report-design-form', + 'deleteFormId' => 'security-report-logo-delete-form', + ]; + require templatePath('partials/app-file-upload.phtml'); + ?> +
+ +
+
+

+

+
+ +
+ + +
+

+

+
+ $description): ?> +
+
+ +
+
+ +
+ + +
+ + +
+
diff --git a/modules/security/pages/security/report-design/logo-file().php b/modules/security/pages/security/report-design/logo-file().php new file mode 100644 index 0000000..bc42d9e --- /dev/null +++ b/modules/security/pages/security/report-design/logo-file().php @@ -0,0 +1,31 @@ +queryAll(); +$size = isset($query['size']) ? (int) $query['size'] : null; + +$path = $logoService->findLogoPath($size); +if (!$path || !is_file($path)) { + http_response_code(404); + + return; +} + +$mime = $logoService->detectMime($path); +header('Content-Type: ' . $mime); +header('X-Content-Type-Options: nosniff'); +header('Content-Security-Policy: sandbox'); +header('Cache-Control: private, max-age=300'); +header('Content-Length: ' . filesize($path)); +readfile($path); 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..5777c31 --- /dev/null +++ b/modules/security/templates/aside-security-panel.phtml @@ -0,0 +1,89 @@ + '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, + ], + [ + 'label' => t('Report design'), + 'path' => 'security/report-design', + 'active' => $reportDesignActive, + 'visible' => $canManageSettings, + ], + ], + ], +]; +?> + 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..86f816f --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityCheckProcessTest.php @@ -0,0 +1,235 @@ +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 testReportStepRequiresOfficialCustomerReportField(): void + { + $process = new SecurityCheckProcess(); + + $fields = $process->stepFields(SecurityCheckProcess::STEP_REPORT); + $this->assertSame( + [SecurityCheckProcess::FIELD_CUSTOMER_REPORT], + array_column($fields, 'key') + ); + + // The owner must fill it before the final step can be completed. + $this->assertContains( + SecurityCheckProcess::FIELD_CUSTOMER_REPORT, + $process->requiredFieldKeys(SecurityCheckProcess::STEP_REPORT) + ); + } + + 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..bd4d6c9 --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityCheckServiceTest.php @@ -0,0 +1,351 @@ +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', + ]; + $step[SecurityCheckProcess::STEP_REPORT]['fields'] = [ + SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'All checks passed; no action required.', + ]; + $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']); + $this->assertSame( + 'All checks passed; no action required.', + $process_state[SecurityCheckProcess::STEP_REPORT]['fields'][SecurityCheckProcess::FIELD_CUSTOMER_REPORT] + ); + + $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('