feat: add administration reporting and correction services
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryService.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
|
||||
|
||||
use App\Domain\Client\ContactEditCommand;
|
||||
use App\Domain\Reporting\ClientHistoryService;
|
||||
use App\Domain\Jobcard\TimeEntryCorrectionCommand;
|
||||
|
||||
function domain_command_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));
|
||||
}
|
||||
|
||||
$contacts = [
|
||||
['id' => 1, 'client_id' => 7, 'name' => 'Jane', 'email' => 'jane@example.test', 'is_primary' => true],
|
||||
['id' => 2, 'client_id' => 7, 'name' => 'John', 'email' => 'john@example.test', 'is_primary' => false],
|
||||
];
|
||||
$contactCommand = new ContactEditCommand();
|
||||
$edit = $contactCommand->validateEdit(2, ['client_id' => 7, 'name' => ' John Smith ', 'is_primary' => 'yes'], $contacts);
|
||||
domain_command_assert_same(true, $edit['valid'], 'A contact edit should validate.');
|
||||
domain_command_assert_same([1], $edit['replace_primary_contact_ids'], 'Promoting an edited contact should demote the old primary.');
|
||||
$delete = $contactCommand->validateDelete(1, $contacts);
|
||||
domain_command_assert_same(true, $delete['valid'], 'A primary contact may be deleted when a replacement exists.');
|
||||
domain_command_assert_same(2, $delete['replacement_primary_contact_id'], 'Deleting the primary should nominate a replacement.');
|
||||
$last = $contactCommand->validateDelete(2, [['id' => 2, 'client_id' => 7, 'is_primary' => true]]);
|
||||
domain_command_assert_same(false, $last['valid'], 'Deleting the only contact must be rejected.');
|
||||
|
||||
$history = new ClientHistoryService();
|
||||
$rows = [
|
||||
['id' => 2, 'client_id' => 7, 'changed_at' => '2026-09-03', 'to_status' => 'closed'],
|
||||
['id' => 1, 'client_id' => 8, 'changed_at' => '2026-09-02', 'to_status' => 'open'],
|
||||
['id' => 3, 'client_id' => 7, 'changed_at' => '2026-10-01', 'to_status' => 'open'],
|
||||
];
|
||||
domain_command_assert_same([2], array_column($history->filter($rows, ['client_id' => 7, 'date_to' => '2026-09-30']), 'id'), 'History filtering should apply client and date bounds.');
|
||||
|
||||
$correction = new TimeEntryCorrectionCommand();
|
||||
$existing = ['id' => 9, 'jobcard_id' => 12, 'technician_id' => 4, 'work_date' => '2026-09-01', 'hours' => 2, 'notes' => 'old', 'counts_toward_sla' => true, 'voided' => false];
|
||||
$fixed = $correction->validateCorrection($existing, ['hours' => '3.25', 'notes' => ' corrected ']);
|
||||
domain_command_assert_same(true, $fixed['valid'], 'A time correction should validate against the existing entry.');
|
||||
domain_command_assert_same(3.25, $fixed['entry']['hours'], 'A time correction should normalize replacement hours.');
|
||||
domain_command_assert_same(12, $fixed['entry']['jobcard_id'], 'A correction must retain the existing jobcard.');
|
||||
$void = $correction->validateVoid($existing, ['reason' => 'Duplicate entry']);
|
||||
domain_command_assert_same(true, $void['valid'], 'Voiding should require and retain a reason.');
|
||||
domain_command_assert_same('void', $void['action'], 'Voiding should be an explicit logical action.');
|
||||
$voided = $correction->validateVoid([...$existing, 'voided' => true], ['reason' => 'again']);
|
||||
domain_command_assert_same(false, $voided['valid'], 'An already voided entry cannot be voided twice.');
|
||||
|
||||
printf("Domain command/service tests: 10 passed\n");
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
|
||||
|
||||
use App\Domain\Notification\NotificationRecord;
|
||||
use App\Domain\Notification\NotificationQueue;
|
||||
|
||||
function 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));
|
||||
}
|
||||
}
|
||||
|
||||
$records = new NotificationRecord();
|
||||
notification_assert_same(false, $records->normalize(['type' => 'assignment_created', 'recipient' => 'A@EXAMPLE.COM', 'read' => 'unread', 'dedup_key' => ' Assignment:7 '])['is_read'], 'Unread aliases should normalize to false.');
|
||||
notification_assert_same(true, $records->normalize(['type' => 'assignment_created', 'recipient' => 'a@example.com', 'read' => 'read', 'dedup_key' => 'assignment:7'])['is_read'], 'Read aliases should normalize to true.');
|
||||
notification_assert_same(true, $records->validateMarkRead(['notification_id' => '12'])['valid'], 'A positive notification ID should be accepted for marking read.');
|
||||
notification_assert_same(12, $records->validateMarkRead(['id' => '12'])['notification_id'], 'Mark-read IDs should normalize to integers.');
|
||||
notification_assert_same(false, $records->validateMarkRead(['notification_id' => '0'])['valid'], 'Zero is not a valid notification ID.');
|
||||
|
||||
$assignment = $records->assignmentCreated([
|
||||
'jobcard_id' => '42', 'jobcard_reference' => 'JC-2026-000042',
|
||||
'recipients' => ['Tech@Example.com'], 'technician_name' => ' Sam ',
|
||||
]);
|
||||
notification_assert_same('assignment_created', $assignment['type'], 'Assignment factory should set its event type.');
|
||||
notification_assert_same('assignment:42', $assignment['deduplication_key'], 'Assignment factory should use a stable deduplication key.');
|
||||
notification_assert_same(['tech@example.com'], $assignment['recipients'], 'Factories should normalize recipients.');
|
||||
|
||||
$status = $records->statusChanged(['jobcard_id' => 42, 'from_status' => 'new', 'to_status' => 'assigned', 'recipients' => ['ops@example.com']]);
|
||||
notification_assert_same('jobcard_status_changed', $status['type'], 'Status factory should set its event type.');
|
||||
notification_assert_same('jobcard:42:status:assigned', $status['deduplication_key'], 'Status factory should key by jobcard and destination status.');
|
||||
|
||||
$sla = $records->slaThreshold(['client_id' => 9, 'threshold' => 'critical', 'used_hours' => 9, 'allocated_hours' => 10, 'recipients' => ['ops@example.com']]);
|
||||
notification_assert_same('sla_threshold', $sla['type'], 'SLA factory should set its event type.');
|
||||
notification_assert_same('sla:9:critical', $sla['deduplication_key'], 'SLA factory should key by client and threshold.');
|
||||
|
||||
$mapped = (new NotificationQueue())->mapForUser(['type' => 'assignment_created', 'recipients' => ['a@example.com', 'b@example.com'], 'title' => 'Assigned', 'deduplication_key' => 'assignment:42'], 'b@example.com');
|
||||
notification_assert_same(['type' => 'assignment_created', 'recipient' => 'b@example.com', 'title' => 'Assigned', 'body' => null, 'is_read' => false, 'deduplication_key' => 'assignment:42'], $mapped, 'Queue mapping should produce one per-user DTO.');
|
||||
notification_assert_same(2, count($records->toQueueDtos(['type' => 'assignment_created', 'recipients' => ['a@example.com', 'b@example.com'], 'title' => 'Assigned', 'deduplication_key' => 'assignment:42'])), 'Queue DTO mapping should produce one DTO per recipient.');
|
||||
|
||||
printf("Notification domain tests: 11 passed\n");
|
||||
@@ -6,6 +6,7 @@ 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/SlaReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
|
||||
|
||||
$filters = ReportFilters::fromArray([
|
||||
@@ -47,9 +48,38 @@ if ($activity !== [['technician_id' => 4, 'technician_name' => 'Tess', 'client_i
|
||||
throw new RuntimeException('Technician activity must aggregate hours deterministically.');
|
||||
}
|
||||
|
||||
$crossClientActivity = (new TechnicianActivityReport())->build([
|
||||
['id' => 20, 'technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 8, 'client_name' => 'Beta', 'work_date' => '2026-09-02', 'hours' => 2, 'counts_toward_sla' => true, 'credentials' => 'secret'],
|
||||
['id' => 10, 'technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1, 'counts_toward_sla' => true, 'internal_notes' => 'secret'],
|
||||
], 'internal');
|
||||
if ($crossClientActivity !== [
|
||||
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 1.0, 'sla_hours' => 1.0],
|
||||
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 8, 'client_name' => 'Beta', 'hours' => 2.0, 'sla_hours' => 2.0],
|
||||
]) {
|
||||
throw new RuntimeException('Technician activity must preserve client attribution and sort by technician then client.');
|
||||
}
|
||||
$clientActivity = (new TechnicianActivityReport())->build([
|
||||
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1, 'counts_toward_sla' => true, 'password' => 'secret'],
|
||||
], 'client');
|
||||
if ($clientActivity !== [['client_id' => 7, 'client_name' => 'Acme', 'hours' => 1.0, 'sla_hours' => 1.0]]) {
|
||||
throw new RuntimeException('Client technician activity projection must exclude technician and secret fields.');
|
||||
}
|
||||
|
||||
$filteredSla = (new SlaReport(ReportFilters::fromArray(['client_id' => 7, 'date_from' => '2026-09-01', 'date_to' => '2026-09-30'])))->build([
|
||||
['client_id' => 8, 'client_name' => 'Beta', 'allocated_hours' => 10, 'hours' => [1], 'start_date' => '2026-09-01'],
|
||||
['client_id' => 7, 'client_name' => 'Acme', 'allocated_hours' => 10, 'hours' => [2], 'start_date' => '2026-09-01', 'credentials' => 'secret'],
|
||||
], 'client');
|
||||
if ($filteredSla !== [['client_id' => 7, 'client_name' => 'Acme', 'allocated_hours' => 10.0, 'used_hours' => 2.0, 'remaining_hours' => 8.0, 'usage_percentage' => 20.0, 'status' => 'within_limit']]) {
|
||||
throw new RuntimeException('SLA report must apply filters and expose a safe audience projection.');
|
||||
}
|
||||
|
||||
$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.');
|
||||
}
|
||||
$escapedHtml = (new PrintReportRenderer())->render('Report <x>', ['Value'], [['<script>alert(1)</script>']]);
|
||||
if (str_contains($escapedHtml, '<script>alert') || !str_contains($escapedHtml, '<script>')) {
|
||||
throw new RuntimeException('PDF-ready HTML must escape report content.');
|
||||
}
|
||||
|
||||
printf("Report workflow tests: 5 passed\n");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
|
||||
require_once __DIR__ . '/../bin/healthcheck.php';
|
||||
|
||||
use App\Domain\Attachment\AttachmentValidator;
|
||||
use App\Domain\Credential\CredentialVault;
|
||||
|
||||
function security_regression_assert(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function security_regression_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));
|
||||
}
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$bootstrap = file_get_contents($root . '/config/bootstrap.php');
|
||||
$frontController = file_get_contents($root . '/public/index.php');
|
||||
$schema = file_get_contents($root . '/database/schema.sql');
|
||||
$upgrade = file_get_contents($root . '/database/upgrade.sql');
|
||||
security_regression_assert($bootstrap !== false && $frontController !== false && $schema !== false && $upgrade !== false, 'Security regression fixtures must be readable.');
|
||||
|
||||
$checks = 0;
|
||||
|
||||
// A technician access decision must bind the requested jobcard to the session user.
|
||||
security_regression_assert(
|
||||
preg_match('/function can_access_jobcard\(int \$jobcardId\).*?SELECT 1 FROM jobcard_assignments.*?jobcard_id = :jobcard.*?user_id = :user/s', $bootstrap) === 1,
|
||||
'Jobcard access must scope by both jobcard ID and authenticated technician ID.'
|
||||
);
|
||||
security_regression_assert(
|
||||
preg_match('/if \(!can_access_jobcard\(\$jobcardId\)\).*?Jobcard not found/s', $frontController) === 1,
|
||||
'Direct technician jobcard IDs must be denied when the assignment is not theirs.'
|
||||
);
|
||||
security_regression_assert(
|
||||
preg_match('/SELECT DISTINCT c\.id, c\.name.*?jobcard_assignments.*?ja\.user_id = :user/s', $frontController) === 1,
|
||||
'Client lists must not become a cross-client oracle for technicians.'
|
||||
);
|
||||
$checks++;
|
||||
|
||||
// The technician report route must sum only the logged-in technician's entries,
|
||||
// even when an assigned jobcard contains entries recorded by other technicians.
|
||||
security_regression_assert(
|
||||
preg_match('/role_name.*?Technician.*?SUM\(CASE WHEN te\.technician_id = :user THEN te\.hours ELSE 0 END\).*?ja\.user_id = :user_assigned/s', $frontController) === 1,
|
||||
'Technician report SQL must isolate hours to the authenticated technician.'
|
||||
);
|
||||
$activity = (new TechnicianActivityReport())->build([
|
||||
['technician_id' => 11, 'technician_name' => 'Own Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2.25, 'counts_toward_sla' => true],
|
||||
['technician_id' => 12, 'technician_name' => 'Other Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 9.50, 'counts_toward_sla' => true],
|
||||
]);
|
||||
security_regression_assert_same(2, count($activity), 'Activity aggregation must keep technicians distinct before route-level ownership filtering.');
|
||||
security_regression_assert_same(2.25, $activity[1]['hours'], 'Own-technician report fixtures must preserve the own-technician total.');
|
||||
security_regression_assert_same(9.5, $activity[0]['hours'], 'Cross-technician fixture must remain distinguishable for isolation assertions.');
|
||||
$checks++;
|
||||
|
||||
// Credentials use the canonical ciphertext field and never persist/return plaintext.
|
||||
$key = base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES));
|
||||
$vault = new CredentialVault($key);
|
||||
$secret = 'cross-client-secret-' . bin2hex(random_bytes(8));
|
||||
$stored = $vault->encryptCredential(['id' => 4, 'category' => 'hosting', 'label' => 'Production', 'username' => 'deploy', 'notes' => 'private', 'secret' => $secret]);
|
||||
security_regression_assert(!array_key_exists('secret', $stored), 'Stored credentials must not contain a plaintext secret field.');
|
||||
security_regression_assert(isset($stored['secret_ciphertext']) && is_string($stored['secret_ciphertext']), 'Stored credentials must use secret_ciphertext as the canonical field.');
|
||||
security_regression_assert(!str_contains(serialize($stored), $secret), 'Serialized stored credentials must not contain the plaintext secret.');
|
||||
security_regression_assert_same($secret, $vault->decryptCredential($stored)['secret'], 'Canonical ciphertext must decrypt only with the owning vault key.');
|
||||
security_regression_assert(!array_key_exists('secret', $vault->projectMetadata($stored)), 'Metadata projections must exclude plaintext secrets.');
|
||||
$checks++;
|
||||
|
||||
// Upload metadata must reject traversal, executable double extensions, MIME mismatches,
|
||||
// oversized files, and unapproved client-visible state.
|
||||
$attachments = new AttachmentValidator(1_000);
|
||||
$unsafe = [
|
||||
['name' => '../outside.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' => 1_001],
|
||||
['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10, 'client_visible' => true, 'client_approved' => false],
|
||||
];
|
||||
foreach ($unsafe as $payload) {
|
||||
security_regression_assert($attachments->validate($payload)['valid'] === false, 'Unsafe attachment metadata must be rejected.');
|
||||
}
|
||||
$safe = $attachments->validate(['original_name' => ' evidence.PNG ', 'mime' => 'IMAGE/PNG', 'size' => '42']);
|
||||
security_regression_assert_same(true, $safe['valid'], 'Safe attachment metadata should be accepted.');
|
||||
security_regression_assert_same(['name' => 'evidence.PNG', 'extension' => 'png', 'mime_type' => 'image/png', 'size_bytes' => 42, 'client_visible' => false, 'client_approved' => false], array_intersect_key($safe, array_flip(['name', 'extension', 'mime_type', 'size_bytes', 'client_visible', 'client_approved'])), 'Attachment metadata must normalize to safe canonical fields.');
|
||||
$checks++;
|
||||
|
||||
// Healthcheck schema coverage must include every table required by the current schema.
|
||||
final class SecurityRegressionFakePdo extends PDO
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $queries = [];
|
||||
public function __construct() {}
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
|
||||
{
|
||||
$this->queries[] = $query;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$fakePdo = new SecurityRegressionFakePdo();
|
||||
security_regression_assert(deployment_check_schema($fakePdo), 'Healthcheck schema probe should succeed for all required table probes.');
|
||||
$probedTables = array_map(static fn(string $query): string => trim(str_replace(['SELECT 1 FROM', '`', 'LIMIT 1'], '', $query)), $fakePdo->queries);
|
||||
preg_match_all('/CREATE TABLE IF NOT EXISTS ([a-z0-9_]+)/i', $schema, $schemaMatches);
|
||||
$schemaTables = array_values(array_unique(array_map('strtolower', $schemaMatches[1])));
|
||||
security_regression_assert_same($schemaTables, $probedTables, 'Healthcheck schema requirements must cover exactly the bootstrap schema tables.');
|
||||
$checks++;
|
||||
|
||||
// Every state-changing route/form must retain the central CSRF guard assumption.
|
||||
foreach (['logout', 'login', 'jobcard', 'client', 'clients', 'users'] as $route) {
|
||||
security_regression_assert(str_contains($frontController, "\$route === '{$route}'") || ($route === 'login' && str_contains($frontController, "\$route === 'login'")), "Expected explicit {$route} route in front controller.");
|
||||
}
|
||||
security_regression_assert(substr_count($frontController, 'verify_csrf();') >= 7, 'All supported POST route paths must call verify_csrf before mutation.');
|
||||
security_regression_assert(str_contains($frontController, "if (\$route === 'logout')") && str_contains($frontController, "if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')"), 'Logout must remain POST-only and CSRF-protected.');
|
||||
$checks++;
|
||||
|
||||
// Upgrade coverage is additive and must include each feature table introduced after
|
||||
// the original foundation, plus the SLA uniqueness remediation guard.
|
||||
foreach (['technical_information', 'credentials', 'jobcard_sequences', 'jobcard_status_history', 'attachments', 'notifications'] as $table) {
|
||||
security_regression_assert((bool)preg_match('/CREATE TABLE IF NOT EXISTS ' . preg_quote($table, '/') . '\\b/i', $upgrade), "Migration must cover {$table}.");
|
||||
}
|
||||
security_regression_assert(str_contains($upgrade, 'sla_client_unique') && str_contains($upgrade, 'sla_duplicate_count'), 'Migration must safely remediate duplicate SLA rows before adding the unique constraint.');
|
||||
security_regression_assert(str_contains($upgrade, 'INSERT IGNORE INTO permissions') && str_contains($upgrade, 'attachments.view'), 'Migration must cover permissions for migrated feature modules.');
|
||||
$checks++;
|
||||
|
||||
printf("Security regression tests: %d passed\n", $checks);
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php';
|
||||
|
||||
use App\Domain\Credential\TechnicalInformationRepository;
|
||||
|
||||
final class TechnicalInformationFakeStatement extends PDOStatement
|
||||
{
|
||||
/** @var callable */
|
||||
private $executor;
|
||||
private mixed $result = null;
|
||||
|
||||
public function __construct(callable $executor)
|
||||
{
|
||||
$this->executor = $executor;
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
$this->result = ($this->executor)($params ?? []);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function fetch(int $mode = PDO::FETCH_DEFAULT, int ...$args): mixed
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
public function fetchAll(int $mode = PDO::FETCH_DEFAULT, mixed ...$args): array
|
||||
{
|
||||
return $this->result ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
final class TechnicalInformationFakePdo extends PDO
|
||||
{
|
||||
/** @var list<array{sql:string,params:array}> */
|
||||
public array $calls = [];
|
||||
public ?array $row = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return new TechnicalInformationFakeStatement(function (array $params) use ($query): mixed {
|
||||
$this->calls[] = ['sql' => $query, 'params' => $params];
|
||||
if (str_starts_with($query, 'INSERT')) {
|
||||
$this->row = [
|
||||
'id' => $this->row['id'] ?? 41,
|
||||
'client_id' => $params['client_id'],
|
||||
'category' => $params['category'],
|
||||
'data_json' => $params['data_json'],
|
||||
'updated_by' => $params['updated_by'],
|
||||
'created_at' => '2026-09-01 10:00:00',
|
||||
'updated_at' => '2026-09-01 10:05:00',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
return $this->row;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function technical_repository_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));
|
||||
}
|
||||
}
|
||||
|
||||
$pdo = new TechnicalInformationFakePdo();
|
||||
$repository = new TechnicalInformationRepository($pdo);
|
||||
$result = $repository->upsert(7, ' VPN ', ['label' => ' Office VPN ', 'username' => ' alice ', 'notes' => ' MFA enabled '], 12);
|
||||
|
||||
technical_repository_assert_same(41, $result['id'], 'Upsert must return the persisted record id.');
|
||||
technical_repository_assert_same('vpn', $result['category'], 'Upsert must normalize the category before persistence.');
|
||||
technical_repository_assert_same(['label' => 'Office VPN', 'username' => 'alice', 'notes' => 'MFA enabled'], $result['data'], 'Upsert must return normalized JSON data.');
|
||||
technical_repository_assert_same(['id' => 41, 'client_id' => 7, 'category' => 'vpn', 'label' => 'Office VPN', 'username' => 'alice', 'notes' => 'MFA enabled'], $result['display'], 'Display projection must be allow-listed and safe.');
|
||||
technical_repository_assert_same(['event' => 'technical_information.upserted', 'entity_type' => 'technical_information', 'entity_id' => 41, 'client_id' => 7, 'category' => 'vpn', 'updated_by' => 12], $result['audit'], 'Return value must include audit-ready identifiers and actor.');
|
||||
|
||||
$call = $pdo->calls[0] ?? [];
|
||||
if (!str_contains($call['sql'] ?? '', 'ON DUPLICATE KEY UPDATE') || ($call['params']['data_json'] ?? '') !== '{"label":"Office VPN","username":"alice","notes":"MFA enabled"}') {
|
||||
throw new RuntimeException('Repository must use a parameterized client/category upsert with canonical JSON.');
|
||||
}
|
||||
|
||||
$found = $repository->find(7, 'vpn');
|
||||
technical_repository_assert_same($result['id'], $found['id'], 'Find must read back the upserted row by client and category.');
|
||||
|
||||
try {
|
||||
$repository->upsert(7, 'unknown', ['label' => 'Bad']);
|
||||
throw new RuntimeException('Invalid technical-information categories must be rejected.');
|
||||
} catch (InvalidArgumentException $expected) {
|
||||
}
|
||||
|
||||
try {
|
||||
$repository->upsert(0, 'vpn', ['label' => 'Bad']);
|
||||
throw new RuntimeException('Invalid client ids must be rejected.');
|
||||
} catch (InvalidArgumentException $expected) {
|
||||
}
|
||||
|
||||
printf("Technical information repository tests: 5 passed\n");
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/UserAdminService.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
|
||||
|
||||
use App\Domain\User\RolePermissionService;
|
||||
use App\Domain\User\UserAdminService;
|
||||
|
||||
function user_admin_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));
|
||||
}
|
||||
}
|
||||
|
||||
$users = new UserAdminService();
|
||||
$existing = [
|
||||
['id' => 7, 'name' => 'Alice Example', 'email' => 'alice@example.test', 'role_name' => 'Accounts', 'role_id' => 2, 'is_active' => true],
|
||||
['id' => 8, 'name' => 'Bob Example', 'email' => 'bob@example.test', 'role_name' => 'Technician', 'role_id' => 3, 'is_active' => false],
|
||||
];
|
||||
|
||||
$edited = $users->validateForEdit(7, [
|
||||
'name' => ' Alice Updated ', 'email' => ' ALICE.UPDATED@EXAMPLE.TEST ', 'role_id' => '3', 'is_active' => 'yes',
|
||||
], $existing);
|
||||
user_admin_assert_same(true, $edited['valid'], 'A valid user edit should pass.');
|
||||
user_admin_assert_same('alice.updated@example.test', $edited['email'], 'User edit email should be normalized.');
|
||||
user_admin_assert_same(7, $edited['id'], 'User edit should retain the explicit ID.');
|
||||
|
||||
$duplicate = $users->validateForEdit(7, [
|
||||
'name' => 'Alice Updated', 'email' => ' bob@example.test ', 'role_id' => 2, 'is_active' => true,
|
||||
], $existing);
|
||||
user_admin_assert_same(false, $duplicate['valid'], 'A duplicate user email should fail edit validation.');
|
||||
if (!isset($duplicate['errors']['email'])) throw new RuntimeException('Duplicate user email should produce an email error.');
|
||||
|
||||
user_admin_assert_same(
|
||||
['valid' => true, 'id' => 7, 'is_active' => false, 'errors' => []],
|
||||
$users->validateDeactivate($existing[0]),
|
||||
'Active users should be deactivatable.'
|
||||
);
|
||||
user_admin_assert_same(
|
||||
['valid' => true, 'id' => 8, 'is_active' => true, 'errors' => []],
|
||||
$users->validateReactivate($existing[1]),
|
||||
'Inactive users should be reactivatable.'
|
||||
);
|
||||
|
||||
$protected = ['id' => 1, 'name' => 'Root', 'email' => 'root@example.test', 'role_name' => ' Administrator ', 'role_id' => 1, 'is_active' => true];
|
||||
$protectedResult = $users->validateDeactivate($protected);
|
||||
user_admin_assert_same(false, $protectedResult['valid'], 'The Administrator account must not be deactivated.');
|
||||
if (!isset($protectedResult['errors']['role'])) throw new RuntimeException('Protected Administrator deactivation should produce a role error.');
|
||||
|
||||
$reset = $users->validatePasswordReset($existing[0], 'Unique&Secure123');
|
||||
user_admin_assert_same(true, $reset['valid'], 'A strong password reset should pass.');
|
||||
if (array_key_exists('password', $reset)) throw new RuntimeException('Password reset validation must not return plaintext passwords.');
|
||||
$weakReset = $users->validatePasswordReset($existing[0], 'Password123!');
|
||||
user_admin_assert_same(false, $weakReset['valid'], 'Weak password reset input should fail.');
|
||||
if (!isset($weakReset['errors']['password'])) throw new RuntimeException('Weak password reset should produce a password error.');
|
||||
$payloadReset = $users->validateReset(['password' => 'Unique&Secure123']);
|
||||
user_admin_assert_same(true, $payloadReset['valid'], 'Password-only reset payloads should be supported.');
|
||||
|
||||
$roles = new RolePermissionService();
|
||||
$available = ['clients.view', 'clients.manage', 'reports.view'];
|
||||
$assignment = $roles->validateAssignment(['id' => 4, 'name' => 'Support'], [' CLIENTS.VIEW ', 'reports.view', 'clients.view'], $available);
|
||||
user_admin_assert_same(true, $assignment['valid'], 'Known permissions should be assignable to a custom role.');
|
||||
user_admin_assert_same(['clients.view', 'reports.view'], $assignment['permissions'], 'Permission assignments should be canonical and de-duplicated.');
|
||||
|
||||
$unknown = $roles->validateAssignment(['id' => 4, 'name' => 'Support'], ['clients.view', 'users.delete'], $available);
|
||||
user_admin_assert_same(false, $unknown['valid'], 'Unknown permissions must not be assignable.');
|
||||
if (!isset($unknown['errors']['permissions'])) throw new RuntimeException('Unknown permission should produce a permissions error.');
|
||||
|
||||
$protectedRole = $roles->validateAssignment(['id' => 1, 'name' => 'Administrator'], ['clients.view'], $available);
|
||||
user_admin_assert_same(false, $protectedRole['valid'], 'Administrator permissions must be protected.');
|
||||
if (!isset($protectedRole['errors']['role'])) throw new RuntimeException('Administrator permission changes should produce a role error.');
|
||||
|
||||
printf("User administration service tests: 8 passed\n");
|
||||
Reference in New Issue
Block a user