forked from fa/breadcrumb-the-shire
Added security check report field and variable
This commit is contained in:
@@ -75,6 +75,8 @@
|
|||||||
"Customer name": "Kundenname",
|
"Customer name": "Kundenname",
|
||||||
"Customer number": "Kundennummer",
|
"Customer number": "Kundennummer",
|
||||||
"Responsible person": "Verantwortliche Person",
|
"Responsible person": "Verantwortliche Person",
|
||||||
|
"Official customer report": "Offizieller Kundenbericht",
|
||||||
|
"Official customer report text (written by the owner on the report step)": "Offizieller Kundenbericht-Text (vom Verantwortlichen im Berichtsschritt verfasst)",
|
||||||
"Completion percentage": "Fortschritt in Prozent",
|
"Completion percentage": "Fortschritt in Prozent",
|
||||||
"Completed technical checks": "Abgeschlossene technische Prüfungen",
|
"Completed technical checks": "Abgeschlossene technische Prüfungen",
|
||||||
"Total technical checks": "Technische Prüfungen gesamt",
|
"Total technical checks": "Technische Prüfungen gesamt",
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
"Customer name": "Customer name",
|
"Customer name": "Customer name",
|
||||||
"Customer number": "Customer number",
|
"Customer number": "Customer number",
|
||||||
"Responsible person": "Responsible person",
|
"Responsible person": "Responsible person",
|
||||||
|
"Official customer report": "Official customer report",
|
||||||
|
"Official customer report text (written by the owner on the report step)": "Official customer report text (written by the owner on the report step)",
|
||||||
"Completion percentage": "Completion percentage",
|
"Completion percentage": "Completion percentage",
|
||||||
"Completed technical checks": "Completed technical checks",
|
"Completed technical checks": "Completed technical checks",
|
||||||
"Total technical checks": "Total technical checks",
|
"Total technical checks": "Total technical checks",
|
||||||
|
|||||||
@@ -32,10 +32,13 @@ final class SecurityCheckProcess
|
|||||||
public const FIELD_TEXTAREA = 'textarea';
|
public const FIELD_TEXTAREA = 'textarea';
|
||||||
public const FIELD_DATETIME = 'datetime';
|
public const FIELD_DATETIME = 'datetime';
|
||||||
|
|
||||||
|
/** Field key of the official, customer-facing report written on the final step. */
|
||||||
|
public const FIELD_CUSTOMER_REPORT = 'customer_report';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered fixed process steps.
|
* Ordered fixed process steps.
|
||||||
*
|
*
|
||||||
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool}>}>
|
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool, rows?: int}>}>
|
||||||
*/
|
*/
|
||||||
public function steps(): array
|
public function steps(): array
|
||||||
{
|
{
|
||||||
@@ -86,10 +89,13 @@ final class SecurityCheckProcess
|
|||||||
'kind' => self::KIND_TECH_CHECKLIST,
|
'kind' => self::KIND_TECH_CHECKLIST,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'report',
|
'key' => self::STEP_REPORT,
|
||||||
'label' => 'Create report & notify customer',
|
'label' => 'Create report & notify customer',
|
||||||
'description' => 'Create the report for the customer and notify them.',
|
'description' => 'Create the report for the customer and notify them.',
|
||||||
'kind' => self::KIND_STEP,
|
'kind' => self::KIND_STEP,
|
||||||
|
'fields' => [
|
||||||
|
['key' => self::FIELD_CUSTOMER_REPORT, 'label' => 'Official customer report', 'type' => self::FIELD_TEXTAREA, 'required' => true, 'rows' => 12],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -114,7 +120,7 @@ final class SecurityCheckProcess
|
|||||||
/**
|
/**
|
||||||
* Structured fields captured on a given step (info textareas, datetimes, …).
|
* Structured fields captured on a given step (info textareas, datetimes, …).
|
||||||
*
|
*
|
||||||
* @return list<array{key: string, label: string, type: string, required: bool}>
|
* @return list<array{key: string, label: string, type: string, required: bool, rows?: int}>
|
||||||
*/
|
*/
|
||||||
public function stepFields(string $stepKey): array
|
public function stepFields(string $stepKey): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,8 +106,14 @@ final class SecurityReportPdfService
|
|||||||
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
|
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
|
||||||
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
|
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
|
||||||
|
|
||||||
|
// The owner-authored, customer-facing report text captured on the final step.
|
||||||
|
$reportFields = is_array($processState[SecurityCheckProcess::STEP_REPORT]['fields'] ?? null)
|
||||||
|
? $processState[SecurityCheckProcess::STEP_REPORT]['fields']
|
||||||
|
: [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => t('Security check report'),
|
'title' => t('Security check report'),
|
||||||
|
'customer_report' => trim((string) ($reportFields[SecurityCheckProcess::FIELD_CUSTOMER_REPORT] ?? '')),
|
||||||
'customer_name' => (string) ($check['debitor_name'] ?? ''),
|
'customer_name' => (string) ($check['debitor_name'] ?? ''),
|
||||||
'customer_no' => (string) ($check['debitor_no'] ?? ''),
|
'customer_no' => (string) ($check['debitor_no'] ?? ''),
|
||||||
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
|
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class SecurityReportTemplateService
|
|||||||
public function availableVariables(): array
|
public function availableVariables(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'customer_report' => t('Official customer report text (written by the owner on the report step)'),
|
||||||
'logo_src' => t('Logo image source (data URI) — use in <img src="{{logo_src}}">'),
|
'logo_src' => t('Logo image source (data URI) — use in <img src="{{logo_src}}">'),
|
||||||
'title' => t('Report title'),
|
'title' => t('Report title'),
|
||||||
'customer_name' => t('Customer name'),
|
'customer_name' => t('Customer name'),
|
||||||
@@ -93,6 +94,9 @@ final class SecurityReportTemplateService
|
|||||||
$map['{{' . $key . '}}'] = $this->esc($value);
|
$map['{{' . $key . '}}'] = $this->esc($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owner-authored prose: escape, then keep the author's line breaks.
|
||||||
|
$map['{{customer_report}}'] = nl2br($this->esc((string) ($context['customer_report'] ?? '')));
|
||||||
|
|
||||||
$map['{{checklist}}'] = $this->buildChecklistHtml(
|
$map['{{checklist}}'] = $this->buildChecklistHtml(
|
||||||
is_array($context['tech_sections'] ?? null) ? $context['tech_sections'] : []
|
is_array($context['tech_sections'] ?? null) ? $context['tech_sections'] : []
|
||||||
);
|
);
|
||||||
@@ -214,6 +218,9 @@ final class SecurityReportTemplateService
|
|||||||
<div class="summary">{{checks_done}} / {{checks_total}} ({{percent}}%)</div>
|
<div class="summary">{{checks_done}} / {{checks_total}} ({{percent}}%)</div>
|
||||||
<div class="bar"><div class="bar-fill" style="width: {{percent}}%;"></div></div>
|
<div class="bar"><div class="bar-fill" style="width: {{percent}}%;"></div></div>
|
||||||
|
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="customer-report">{{customer_report}}</div>
|
||||||
|
|
||||||
<h2>Technical checklist</h2>
|
<h2>Technical checklist</h2>
|
||||||
{{checklist}}
|
{{checklist}}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ $completionMeta = static function (array $entry) use ($userNames): string {
|
|||||||
<?php if ($isDatetime): ?>
|
<?php if ($isDatetime): ?>
|
||||||
<input type="datetime-local" id="<?php e($fid); ?>" name="<?php e($fname); ?>" value="<?php e($fval); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
|
<input type="datetime-local" id="<?php e($fid); ?>" name="<?php e($fname); ?>" value="<?php e($fval); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="2"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
|
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="<?php e((string) (int) ($field['rows'] ?? 2)); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ class SecurityCheckProcessTest extends TestCase
|
|||||||
$this->assertContains('external_systems', $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
|
public function testRequiredFieldKeysExcludeOptionalFields(): void
|
||||||
{
|
{
|
||||||
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);
|
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ class SecurityCheckServiceTest extends TestCase
|
|||||||
'blocker_start' => '2026-07-01T08:00',
|
'blocker_start' => '2026-07-01T08:00',
|
||||||
'blocker_end' => '2026-07-02T18:00',
|
'blocker_end' => '2026-07-02T18:00',
|
||||||
];
|
];
|
||||||
|
$step[SecurityCheckProcess::STEP_REPORT]['fields'] = [
|
||||||
|
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'All checks passed; no action required.',
|
||||||
|
];
|
||||||
$input = [
|
$input = [
|
||||||
'step' => $step,
|
'step' => $step,
|
||||||
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
|
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
|
||||||
@@ -143,6 +146,10 @@ class SecurityCheckServiceTest extends TestCase
|
|||||||
$this->assertSame(77, $process_state['beauftragung']['by']);
|
$this->assertSame(77, $process_state['beauftragung']['by']);
|
||||||
$this->assertNotEmpty($process_state['beauftragung']['at']);
|
$this->assertNotEmpty($process_state['beauftragung']['at']);
|
||||||
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
|
$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);
|
$tech_state = json_decode((string) $captured['tech_state_json'], true);
|
||||||
$this->assertTrue($tech_state['tls']['done']);
|
$this->assertTrue($tech_state['tls']['done']);
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
foreach ($process->manualStepKeys() as $key) {
|
foreach ($process->manualStepKeys() as $key) {
|
||||||
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
|
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
|
||||||
}
|
}
|
||||||
|
$processState[SecurityCheckProcess::STEP_REPORT]['fields'] = [
|
||||||
|
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'No critical findings.',
|
||||||
|
];
|
||||||
$snapshot = ['version' => 1, 'items' => [
|
$snapshot = ['version' => 1, 'items' => [
|
||||||
['type' => 'section', 'label' => 'Transport'],
|
['type' => 'section', 'label' => 'Transport'],
|
||||||
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
|
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
|
||||||
@@ -68,6 +71,7 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
$this->assertSame('acme.de', $context['domain']);
|
$this->assertSame('acme.de', $context['domain']);
|
||||||
$this->assertSame('Intranet', $context['product']);
|
$this->assertSame('Intranet', $context['product']);
|
||||||
$this->assertSame('Jane Doe', $context['owner']);
|
$this->assertSame('Jane Doe', $context['owner']);
|
||||||
|
$this->assertSame('No critical findings.', $context['customer_report']);
|
||||||
$this->assertNotSame('', $context['title']);
|
$this->assertNotSame('', $context['title']);
|
||||||
|
|
||||||
// Summary mirrors the progress math (3 tech items, 2 done).
|
// Summary mirrors the progress math (3 tech items, 2 done).
|
||||||
@@ -99,6 +103,7 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame('', $context['customer_name']);
|
$this->assertSame('', $context['customer_name']);
|
||||||
$this->assertSame('', $context['domain']);
|
$this->assertSame('', $context['domain']);
|
||||||
|
$this->assertSame('', $context['customer_report']);
|
||||||
$this->assertSame([], $context['tech_sections']);
|
$this->assertSame([], $context['tech_sections']);
|
||||||
$this->assertSame(0, $context['summary']['total']);
|
$this->assertSame(0, $context['summary']['total']);
|
||||||
$this->assertSame(0, $context['summary']['percent']);
|
$this->assertSame(0, $context['summary']['percent']);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class SecurityReportTemplateServiceTest extends TestCase
|
|||||||
'status_label' => 'In progress',
|
'status_label' => 'In progress',
|
||||||
'generated_at' => '2026-06-22 10:00',
|
'generated_at' => '2026-06-22 10:00',
|
||||||
'app_name' => 'CoreCore',
|
'app_name' => 'CoreCore',
|
||||||
|
'customer_report' => "Line one\nLine two",
|
||||||
'logo_data_uri' => 'data:image/png;base64,AAAA',
|
'logo_data_uri' => 'data:image/png;base64,AAAA',
|
||||||
'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3],
|
'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3],
|
||||||
'tech_sections' => [
|
'tech_sections' => [
|
||||||
@@ -55,6 +56,22 @@ class SecurityReportTemplateServiceTest extends TestCase
|
|||||||
$this->assertStringNotContainsString('<b>Acme</b>', $out);
|
$this->assertStringNotContainsString('<b>Acme</b>', $out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRenderCustomerReportEscapesAndPreservesLineBreaks(): void
|
||||||
|
{
|
||||||
|
$service = new SecurityReportTemplateService();
|
||||||
|
|
||||||
|
$context = $this->context();
|
||||||
|
$context['customer_report'] = "First line <script>\nSecond line";
|
||||||
|
|
||||||
|
$out = $service->render('<section>{{customer_report}}</section>', $context);
|
||||||
|
|
||||||
|
// HTML in the owner's text is escaped...
|
||||||
|
$this->assertStringContainsString('First line <script>', $out);
|
||||||
|
$this->assertStringNotContainsString('<script>', $out);
|
||||||
|
// ...and author line breaks survive as <br>.
|
||||||
|
$this->assertMatchesRegularExpression('/First line <script><br\s*\/?>\s*Second line/', $out);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRenderBuildsChecklistAndProcessHtml(): void
|
public function testRenderBuildsChecklistAndProcessHtml(): void
|
||||||
{
|
{
|
||||||
$service = new SecurityReportTemplateService();
|
$service = new SecurityReportTemplateService();
|
||||||
@@ -111,6 +128,7 @@ class SecurityReportTemplateServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
$variables = (new SecurityReportTemplateService())->availableVariables();
|
$variables = (new SecurityReportTemplateService())->availableVariables();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('customer_report', $variables);
|
||||||
$this->assertArrayHasKey('logo_src', $variables);
|
$this->assertArrayHasKey('logo_src', $variables);
|
||||||
$this->assertArrayHasKey('checklist', $variables);
|
$this->assertArrayHasKey('checklist', $variables);
|
||||||
$this->assertArrayHasKey('process', $variables);
|
$this->assertArrayHasKey('process', $variables);
|
||||||
|
|||||||
Reference in New Issue
Block a user