feat: complete jobcard client management foundation

This commit is contained in:
Marco0300
2026-09-01 20:43:24 +02:00
parent 168c15c6ae
commit de2bf277c4
34 changed files with 2072 additions and 74 deletions
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
use App\Domain\Attachment\AttachmentValidator;
use App\Domain\Notification\NotificationRecord;
function attachment_notification_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$attachments = new AttachmentValidator(5_000_000);
$validAttachment = $attachments->validate([
'name' => ' Site Photo.JPG ',
'mime_type' => 'IMAGE/JPEG',
'size' => '2048',
'client_visible' => 'yes',
'client_approved' => 'true',
]);
attachment_notification_assert_same(true, $validAttachment['valid'], 'A safe approved image attachment should validate.');
attachment_notification_assert_same('Site Photo.JPG', $validAttachment['name'], 'Attachment names should be trimmed without changing case.');
attachment_notification_assert_same('jpg', $validAttachment['extension'], 'Attachment extensions should normalize to lowercase.');
attachment_notification_assert_same('image/jpeg', $validAttachment['mime_type'], 'Attachment MIME types should normalize to lowercase.');
attachment_notification_assert_same(2048, $validAttachment['size_bytes'], 'Attachment sizes should normalize to bytes.');
attachment_notification_assert_same(true, $validAttachment['client_visible'], 'Client visibility should normalize to boolean.');
foreach ([
['name' => '../secret.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10],
['name' => 'invoice.php.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10],
['name' => 'photo.jpg', 'mime_type' => 'application/x-php', 'size_bytes' => 10],
['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 5_000_001],
['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10, 'client_visible' => true, 'client_approved' => false],
] as $invalidPayload) {
attachment_notification_assert_same(false, $attachments->validate($invalidPayload)['valid'], 'Unsafe attachment metadata should be rejected.');
}
$notifications = new NotificationRecord();
$notification = $notifications->validate([
'type' => ' JOBCARD_STATUS_CHANGED ',
'recipients' => [' Support@Example.com ', 'support@example.com', 'client@example.com'],
'is_read' => '0',
'deduplication_key' => ' Jobcard:42:Status:closed ',
]);
attachment_notification_assert_same(true, $notification['valid'], 'A valid notification should validate.');
attachment_notification_assert_same('jobcard_status_changed', $notification['type'], 'Notification types should normalize to lowercase snake case.');
attachment_notification_assert_same(['support@example.com', 'client@example.com'], $notification['recipients'], 'Recipients should normalize, lowercase and de-duplicate.');
attachment_notification_assert_same(false, $notification['is_read'], 'Unread notification state should normalize to false.');
attachment_notification_assert_same('jobcard:42:status:closed', $notification['deduplication_key'], 'Deduplication keys should normalize case and whitespace.');
$invalidNotification = $notifications->validate([
'type' => 'unknown-event',
'recipients' => ['not-an-email'],
'is_read' => 'maybe',
'deduplication_key' => '',
]);
attachment_notification_assert_same(false, $invalidNotification['valid'], 'Invalid notification metadata should be rejected.');
foreach (['type', 'recipients', 'is_read', 'deduplication_key'] as $field) {
if (!isset($invalidNotification['errors'][$field])) {
throw new RuntimeException("Expected validation error for {$field}.");
}
}
printf("Attachment and notification tests: 7 passed\n");
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php';
require_once __DIR__ . '/../app/Domain/Client/ContactUpdateCommand.php';
use App\Domain\Client\ClientUpdateCommand;
use App\Domain\Client\ContactUpdateCommand;
function client_crud_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$clients = [
['id' => 7, 'name' => 'Acme IT', 'status' => 'active'],
['id' => 9, 'name' => 'Other Client', 'status' => 'inactive'],
];
$clientCommand = new ClientUpdateCommand();
$created = $clientCommand->validateForCreate([
'name' => ' New Client ',
'support_email' => ' NEW@EXAMPLE.TEST ',
], $clients);
client_crud_assert_same(true, $created['valid'], 'A unique client should be accepted for creation.');
client_crud_assert_same('New Client', $created['name'], 'Client names should be normalized before duplicate checks.');
client_crud_assert_same('new@example.test', $created['support_email'], 'Client email should be normalized.');
$duplicate = $clientCommand->validateForCreate(['name' => ' acme it '], $clients);
client_crud_assert_same(false, $duplicate['valid'], 'Normalized duplicate client names should be rejected.');
if (!isset($duplicate['errors']['name'])) throw new RuntimeException('Duplicate client names should produce a name error.');
$edited = $clientCommand->validateForEdit(7, ['name' => ' ACME IT ', 'status' => 'active'], $clients);
client_crud_assert_same(true, $edited['valid'], 'Editing a client should ignore its own duplicate row.');
$deactivated = $clientCommand->validateDeactivate(['id' => 7, 'status' => 'active']);
client_crud_assert_same(['valid' => true, 'id' => 7, 'status' => 'inactive', 'errors' => []], $deactivated, 'Active clients should be deactivatable.');
$reactivated = $clientCommand->validateReactivate(['id' => 9, 'status' => 'inactive']);
client_crud_assert_same(['valid' => true, 'id' => 9, 'status' => 'active', 'errors' => []], $reactivated, 'Inactive clients should be reactivatable.');
$contacts = [
['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'is_primary' => true],
];
$contactCommand = new ContactUpdateCommand();
$contact = $contactCommand->validateForCreate([
'client_id' => '7', 'name' => ' John Doe ', 'email' => ' JOHN@EXAMPLE.TEST ', 'is_primary' => 'yes',
], $contacts);
client_crud_assert_same(true, $contact['valid'], 'A unique contact should be accepted.');
client_crud_assert_same(7, $contact['client_id'], 'Contact client IDs should normalize to integers.');
client_crud_assert_same(true, $contact['is_primary'], 'Primary flags should normalize to booleans.');
client_crud_assert_same([11], $contact['replace_primary_contact_ids'], 'Promoting a contact should identify the prior primary contact.');
$contactDuplicate = $contactCommand->validateForCreate(['client_id' => 7, 'name' => ' jane doe ', 'email' => 'other@example.test'], $contacts);
client_crud_assert_same(false, $contactDuplicate['valid'], 'Duplicate contacts should be rejected after normalization.');
if (!isset($contactDuplicate['errors']['name'])) throw new RuntimeException('Duplicate contact names should produce a name error.');
$contactDisplay = $contactCommand->display(['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'phone' => null, 'is_primary' => true, 'password' => 'secret', 'credentials' => 'token', 'internal_secret' => 'omit']);
client_crud_assert_same(['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'phone' => null, 'is_primary' => true], $contactDisplay, 'Contact display projections must exclude credentials and internal secrets.');
printf("Client CRUD tests: 10 passed\n");
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
use App\Domain\Credential\CredentialVault;
use App\Domain\Credential\TechnicalInformation;
function credential_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$key = base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES));
$vault = new CredentialVault($key);
$secret = 'P@ssword & token 123';
$ciphertext = $vault->encrypt($secret);
if ($ciphertext === $secret || $vault->decrypt($ciphertext) !== $secret) {
throw new RuntimeException('Credentials must round-trip through authenticated encryption.');
}
try {
(new CredentialVault(base64_encode(random_bytes(32))))->decrypt($ciphertext);
throw new RuntimeException('Decrypting with the wrong key should fail.');
} catch (RuntimeException $expected) {
if (!str_contains($expected->getMessage(), 'decrypt')) {
throw new RuntimeException('Wrong-key failure should be explicit.');
}
}
$masked = $vault->mask($secret);
credential_assert_same('••••••••••••••••••••', $masked, 'Secrets must never be shown in plaintext.');
credential_assert_same('••••••••••••••••••••', $vault->display(['secret' => $secret])['secret'], 'Display must mask secret fields.');
$credential = [
'id' => 7,
'category' => 'hosting',
'label' => ' Production Host ',
'username' => ' deploy ',
'notes' => ' SSH access ',
'secret' => $secret,
'internal_token' => 'do not expose',
];
$encrypted = $vault->encryptCredential($credential);
if (array_key_exists('secret', $encrypted) || !isset($encrypted['secret_ciphertext'])) {
throw new RuntimeException('Stored credential records must contain ciphertext, not plaintext.');
}
credential_assert_same($secret, $vault->decryptCredential($encrypted)['secret'], 'Credential records must decrypt their secret.');
$projection = $vault->projectMetadata($encrypted);
credential_assert_same(['id' => 7, 'category' => 'hosting', 'label' => 'Production Host', 'username' => 'deploy', 'notes' => 'SSH access'], $projection, 'Metadata projection must be allow-listed and plaintext-free.');
$info = new TechnicalInformation();
$valid = $info->validate(['category' => 'vpn', 'label' => ' Office VPN ', 'username' => ' alice ', 'notes' => ' MFA enabled ']);
credential_assert_same(true, $valid['valid'], 'Valid technical information should pass.');
credential_assert_same('Office VPN', $valid['label'], 'Labels should be normalized.');
$invalid = $info->validate(['category' => 'unknown', 'label' => ' ', 'username' => "bad\nname", 'notes' => str_repeat('x', 2001)]);
if ($invalid['valid'] || !isset($invalid['errors']['category'], $invalid['errors']['label'], $invalid['errors']['username'], $invalid['errors']['notes'])) {
throw new RuntimeException('Technical information must validate category, label, username, and notes.');
}
foreach (['missing' => null, 'short' => 'short', 'placeholder' => 'generate-a-long-random-secret'] as $name => $badKey) {
try {
new CredentialVault($badKey);
throw new RuntimeException("{$name} APP_KEY material should fail explicitly.");
} catch (RuntimeException $expected) {
if (!str_contains($expected->getMessage(), 'APP_KEY')) {
throw new RuntimeException("{$name} APP_KEY error should mention APP_KEY.");
}
}
}
printf("Credential vault tests: 6 passed\n");
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../bin/healthcheck.php';
function deployment_test_assert(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$checks = 0;
$extensions = deployment_check_extensions(
['present_ext', 'missing_ext'],
static fn(string $extension): bool => $extension === 'present_ext',
);
deployment_test_assert($extensions === ['present_ext' => true, 'missing_ext' => false], 'Extension checks must preserve names and availability.');
$checks++;
$environment = deployment_check_environment(
['DB_HOST', 'DB_PASSWORD', 'EMPTY_VALUE'],
static fn(string $name): ?string => ['DB_HOST' => 'localhost', 'DB_PASSWORD' => 'secret', 'EMPTY_VALUE' => ' '][$name] ?? null,
);
deployment_test_assert($environment === ['DB_HOST' => true, 'DB_PASSWORD' => true, 'EMPTY_VALUE' => false], 'Environment checks must only report presence, never values.');
$checks++;
$directories = deployment_check_directories(
['/srv/jobcard/runtime', '/srv/jobcard/uploads'],
static fn(string $directory): bool => $directory === '/srv/jobcard/runtime',
);
deployment_test_assert($directories === ['/srv/jobcard/runtime' => true, '/srv/jobcard/uploads' => false], 'Directory checks must report writability without changing directories.');
$checks++;
$report = deployment_format_check_report([
'DB_PASSWORD' => true,
'DB_HOST' => false,
]);
deployment_test_assert($report === ['DB_PASSWORD' => 'OK', 'DB_HOST' => 'FAIL'], 'Reports must contain statuses only.');
$checks++;
printf("Deployment checks tests: %d passed\n", $checks);
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
$filters = ReportFilters::fromArray([
'client_id' => 7,
'date_from' => '2026-09-01',
'date_to' => '2026-09-30',
'technician_id' => 4,
'status' => 'open',
'priority' => 'high',
'sla' => 'at_risk',
]);
if (!$filters->matches(['client_id' => 7, 'created_at' => '2026-09-10', 'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla_status' => 'at_risk'])) {
throw new RuntimeException('Expected report filters to match all selected criteria.');
}
if ($filters->matches(['client_id' => 7, 'created_at' => '2026-10-01', 'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla_status' => 'at_risk'])) {
throw new RuntimeException('Expected date range to exclude rows outside the range.');
}
$jobcards = (new ClientJobcardReport($filters))->build([
['id' => 2, 'client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-2', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-10', 'technician_id' => 4, 'technician_name' => 'Tess', 'sla_status' => 'at_risk', 'internal_notes' => 'secret', 'credentials' => 'omit'],
['id' => 1, 'client_id' => 8, 'client_name' => 'Beta', 'reference_no' => 'JC-1', 'status' => 'closed', 'priority' => 'low', 'created_at' => '2026-09-01', 'internal_notes' => 'secret'],
], 'client');
if ($jobcards !== [['client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-2', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-10']]) {
throw new RuntimeException('Client jobcard report must filter, sort and allow-list deterministically.');
}
$history = (new ClientHistoryReport())->build([
['jobcard_id' => 3, 'client_id' => 7, 'reference_no' => 'JC-3', 'from_status' => 'new', 'to_status' => 'open', 'changed_at' => '2026-09-03', 'changed_by_name' => 'Tess', 'internal_notes' => 'secret'],
], 'client');
if ($history[0] !== ['client_id' => 7, 'reference_no' => 'JC-3', 'from_status' => 'new', 'to_status' => 'open', 'changed_at' => '2026-09-03']) {
throw new RuntimeException('Client history report must exclude internal actor/details.');
}
$activity = (new TechnicianActivityReport())->build([
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1.25, 'counts_toward_sla' => true, 'internal_notes' => 'secret'],
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-03', 'hours' => 2.75, 'counts_toward_sla' => false],
], 'internal');
if ($activity !== [['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 4.0, 'sla_hours' => 1.25]]) {
throw new RuntimeException('Technician activity must aggregate hours deterministically.');
}
$html = (new PrintReportRenderer())->render('Client Jobcards', ['Reference', 'Status'], [['JC-2', 'open']]);
if (!str_contains($html, '@media print') || !str_contains($html, '<th>Reference</th>') || !str_contains($html, 'JC-2') || str_contains($html, '<script>')) {
throw new RuntimeException('Print report renderer must emit escaped, print-friendly HTML.');
}
printf("Report workflow tests: 5 passed\n");
+1 -1
View File
@@ -30,7 +30,7 @@ reporting_assert_same(
'Client-facing report data must be allow-listed.'
);
reporting_assert_same(
['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'],
['id' => 7, 'status' => 'active', 'internal_notes' => 'never disclose'],
$mapper->internal($record),
'Internal report data must remain separate from client-facing data.'
);
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
use App\Domain\User\PermissionMatrix;
use App\Domain\User\RoleRecord;
function role_permission_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
function role_permission_assert_throws(callable $callback, string $message): void
{
try {
$callback();
} catch (LogicException) {
return;
}
throw new RuntimeException($message);
}
$roles = new RoleRecord();
role_permission_assert_same([
'name' => 'Support Team',
'description' => 'Handles customer support',
], $roles->normalize([
'name' => ' Support Team ',
'description' => ' Handles customer support ',
'permissions' => ['users.manage'],
]), 'Role normalization should trim supported fields and ignore unrelated fields.');
$valid = $roles->validate(['name' => 'Support Team', 'description' => str_repeat('x', 255)]);
role_permission_assert_same(true, $valid['valid'], 'A schema-sized custom role should validate.');
role_permission_assert_same([], $valid['errors'], 'Valid role should have no errors.');
$invalid = $roles->validate(['name' => "\x01", 'description' => str_repeat('x', 256)]);
role_permission_assert_same(false, $invalid['valid'], 'Invalid role data should report invalid.');
if (!isset($invalid['errors']['name'], $invalid['errors']['description'])) {
throw new RuntimeException('Role validation should report name and description errors.');
}
role_permission_assert_throws(
fn() => $roles->assertCanRename(['name' => 'Administrator'], 'Security Administrator'),
'Administrator must not be renamed.'
);
role_permission_assert_throws(
fn() => $roles->assertCanDelete(['name' => ' administrator ']),
'Administrator must not be deleted.'
);
role_permission_assert_throws(
fn() => $roles->assertCanChangePermissions(['name' => 'Administrator']),
'Administrator permissions must not be changed.'
);
role_permission_assert_throws(
fn() => $roles->assertCanRename(['name' => 'Accounts'], ' administrator '),
'A custom role must not be renamed to Administrator.'
);
role_permission_assert_same(true, $roles->canRename(['name' => 'Accounts'], 'Support'), 'Custom roles should be renameable.');
role_permission_assert_same(false, $roles->canDelete(['name' => 'Administrator']), 'Administrator deletion safeguard should be queryable.');
$display = $roles->display([
'id' => 4,
'name' => 'Support Team',
'description' => 'Handles support',
'created_at' => '2026-09-01 08:00:00',
'permissions' => ['clients.view'],
'password_hash' => 'secret',
'internal_notes' => 'omit',
]);
role_permission_assert_same([
'id' => 4,
'name' => 'Support Team',
'description' => 'Handles support',
'created_at' => '2026-09-01 08:00:00',
'permissions' => ['clients.view'],
], $display, 'Role display projection must allow-list safe fields.');
$permissions = new PermissionMatrix();
role_permission_assert_same([
'clients.view',
'jobcards.manage',
'reports.view',
], $permissions->normalize([
' clients.view ',
'jobcards.manage',
'clients.view',
'',
'reports.view',
' ',
]), 'Permissions should be trimmed, blank entries removed, and duplicates de-duplicated.');
role_permission_assert_same([
'clients.view',
'jobcards.manage',
], $permissions->normalize(['CLIENTS.VIEW', 'clients.view', ' jobcards.manage ']), 'Permission normalization should use canonical lower-case names.');
role_permission_assert_same([
'id' => 4,
'name' => 'Support Team',
'permissions' => ['clients.view', 'jobcards.manage'],
], $permissions->display([
'id' => 4,
'name' => 'Support Team',
'permissions' => ['clients.view', 'jobcards.manage', 'clients.view'],
'password' => 'secret',
'token' => 'secret',
]), 'Permission display projection must be safe and normalized.');
printf("Role and permission tests: 10 passed\n");