feat: complete jobcard client management foundation
This commit is contained in:
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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.'
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user