feat: add administration reporting and correction services

This commit is contained in:
Marco0300
2026-09-01 21:03:38 +02:00
parent de2bf277c4
commit b983f90dcb
24 changed files with 1392 additions and 31 deletions
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ContactUpdateCommand.php';
/**
* Storage-agnostic command for safe contact edits, primary promotion and deletion.
* Persistence adapters can apply the returned replacement IDs transactionally.
*/
final class ContactEditCommand
{
public function __construct(private readonly ?ContactUpdateCommand $contacts = null)
{
}
/** @return array<string,mixed> */
public function validateEdit(int $id, array $input, array $existingContacts = []): array
{
$result = ($this->contacts ?? new ContactUpdateCommand())->validateForEdit($id, $input, $existingContacts);
if ($id < 1) {
$result['errors']['id'] = 'Contact ID must be a positive integer.';
$result['valid'] = false;
}
return $result;
}
/** Alias matching the create/update command vocabulary. */
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
{
return $this->validateEdit($id, $input, $existingContacts);
}
/** @return array<string,mixed> */
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
{
return $currentId === null
? ($this->contacts ?? new ContactUpdateCommand())->validateForCreate($input, $existingContacts)
: $this->validateEdit($currentId, $input, $existingContacts);
}
/**
* Validate logical deletion. A primary is promoted to the lowest remaining
* contact for the same client; deleting the sole contact is not allowed.
* @return array<string,mixed>
*/
public function validateDelete(int $id, array $existingContacts = []): array
{
$errors = [];
$target = null;
foreach ($existingContacts as $contact) {
if (is_array($contact) && $this->id($contact['id'] ?? null) === $id) {
$target = $contact;
break;
}
}
if ($id < 1) $errors['id'] = 'Contact ID must be a positive integer.';
if ($target === null) {
$errors['id'] = 'Contact was not found.';
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
}
$clientId = $this->id($target['client_id'] ?? null);
if ($clientId === null) {
$errors['client_id'] = 'Contact client ID must be a positive integer.';
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
}
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $id));
$replacement = null;
if (count($sameClient) === 0) {
$errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
} elseif ($this->boolean($target['is_primary'] ?? false)) {
usort($sameClient, fn (array $a, array $b): int => ($this->id($a['id'] ?? null) ?? PHP_INT_MAX) <=> ($this->id($b['id'] ?? null) ?? PHP_INT_MAX));
$replacement = isset($sameClient[0]) ? $this->id($sameClient[0]['id'] ?? null) : null;
if ($replacement === null) $errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
}
return ['valid' => $errors === [], 'id' => $id, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors];
}
public function validateForDelete(int $id, array $existingContacts = []): array
{
return $this->validateDelete($id, $existingContacts);
}
/** @return array<string,mixed> */
public function delete(int $id, array $existingContacts = []): array
{
return $this->validateDelete($id, $existingContacts);
}
/** @return array<string,mixed> */
public function validatePrimary(int $id, array $existingContacts = []): array
{
foreach ($existingContacts as $row) {
if (is_array($row) && $this->id($row['id'] ?? null) === $id) {
$clientId = $this->id($row['client_id'] ?? null);
$demote = [];
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $id && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
return ['valid' => true, 'id' => $id, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => []];
}
}
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => 'Contact was not found.']];
}
/** @return array<string,mixed> */
public function setPrimary(int $id, array $existingContacts = []): array
{
return $this->validatePrimary($id, $existingContacts);
}
private function id(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return filter_var(trim($value), FILTER_VALIDATE_INT) ?: null;
return null;
}
private function boolean(mixed $value): bool { return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes','on'], true)); }
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
require_once __DIR__ . '/TechnicalInformation.php';
use InvalidArgumentException;
use PDO;
use PDOException;
/**
* PDO persistence for one non-secret technical-information record per client/category.
*
* The repository deliberately stores only the validated metadata fields in JSON and
* returns an allow-listed display projection alongside audit identifiers.
*/
final class TechnicalInformationRepository
{
private TechnicalInformation $information;
public function __construct(private PDO $pdo, ?TechnicalInformation $information = null)
{
$this->information = $information ?? new TechnicalInformation();
}
/**
* Validate, normalize and atomically upsert a record using the schema's
* technical_client_category unique key.
*
* @return array{id:int,client_id:int,category:string,data:array{label:string,username:string|null,notes:string|null},updated_by:int|null,created_at:string|null,updated_at:string|null,display:array<string,mixed>,audit:array<string,mixed>}
*/
public function upsert(int $clientId, string $category, array $data, ?int $updatedBy = null): array
{
$this->assertIds($clientId, $updatedBy);
$validation = $this->information->validate([...$data, 'category' => $category]);
if (!$validation['valid']) {
throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
}
$normalizedCategory = $validation['category'];
$jsonData = [
'label' => $validation['label'],
'username' => $validation['username'],
'notes' => $validation['notes'],
];
$json = json_encode($jsonData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$statement = $this->pdo->prepare(
'INSERT INTO technical_information (client_id, category, data_json, updated_by) '
. 'VALUES (:client_id, :category, :data_json, :updated_by) '
. 'ON DUPLICATE KEY UPDATE data_json = VALUES(data_json), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
$statement->execute([
'client_id' => $clientId,
'category' => $normalizedCategory,
'data_json' => $json,
'updated_by' => $updatedBy,
]);
$record = $this->find($clientId, $normalizedCategory);
if ($record === null) {
throw new PDOException('Technical information upsert did not produce a readable record.');
}
return $this->withAudit($record, 'technical_information.upserted');
}
/** Convenience form for callers holding category inside the information payload. */
public function upsertInformation(int $clientId, array $information, ?int $updatedBy = null): array
{
$category = $information['category'] ?? null;
if (!is_string($category)) {
throw new InvalidArgumentException('Technical information category is required.');
}
unset($information['category']);
return $this->upsert($clientId, $category, $information, $updatedBy);
}
/** @return array<string,mixed>|null */
public function find(int $clientId, string $category): ?array
{
$this->assertIds($clientId, null);
$normalizedCategory = strtolower(trim($category));
if (!in_array($normalizedCategory, TechnicalInformation::categories(), true)) {
throw new InvalidArgumentException('Credential category is invalid.');
}
$statement = $this->pdo->prepare(
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
. 'FROM technical_information WHERE client_id = :client_id AND category = :category LIMIT 1'
);
$statement->execute(['client_id' => $clientId, 'category' => $normalizedCategory]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->hydrate($row) : null;
}
/** @return list<array<string,mixed>> */
public function forClient(int $clientId): array
{
$this->assertIds($clientId, null);
$statement = $this->pdo->prepare(
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
. 'FROM technical_information WHERE client_id = :client_id ORDER BY category ASC, id ASC'
);
$statement->execute(['client_id' => $clientId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn(array $row): array => $this->hydrate($row), $rows);
}
/** @param array<string,mixed> $record @return array<string,mixed> */
public function display(array $record): array
{
$data = is_array($record['data'] ?? null) ? $record['data'] : $this->decodeData($record['data_json'] ?? null);
$display = [];
foreach (['id', 'client_id', 'category'] as $field) {
if (array_key_exists($field, $record)) $display[$field] = $record[$field];
}
foreach (['label', 'username', 'notes'] as $field) {
if (array_key_exists($field, $data)) $display[$field] = $data[$field];
}
return $display;
}
/** @param array<string,mixed> $record @return array<string,mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function hydrate(array $row): array
{
$data = $this->decodeData($row['data_json'] ?? null);
$record = [
'id' => (int) $row['id'],
'client_id' => (int) $row['client_id'],
'category' => (string) $row['category'],
'data' => $data,
'updated_by' => $row['updated_by'] === null ? null : (int) $row['updated_by'],
'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : null,
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
$record['display'] = $this->display($record);
return $record;
}
/** @param array<string,mixed> $record @return array<string,mixed> */
private function withAudit(array $record, string $event): array
{
$record['audit'] = [
'event' => $event,
'entity_type' => 'technical_information',
'entity_id' => $record['id'],
'client_id' => $record['client_id'],
'category' => $record['category'],
'updated_by' => $record['updated_by'],
];
return $record;
}
/** @return array{label:string,username:string|null,notes:string|null} */
private function decodeData(mixed $json): array
{
if (!is_string($json) || $json === '') throw new PDOException('Technical information JSON is missing.');
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) throw new PDOException('Technical information JSON must be an object.');
return [
'label' => is_string($decoded['label'] ?? null) ? $decoded['label'] : '',
'username' => isset($decoded['username']) && is_string($decoded['username']) ? $decoded['username'] : null,
'notes' => isset($decoded['notes']) && is_string($decoded['notes']) ? $decoded['notes'] : null,
];
}
private function assertIds(int $clientId, ?int $updatedBy): void
{
if ($clientId < 1) throw new InvalidArgumentException('Client id must be a positive integer.');
if ($updatedBy !== null && $updatedBy < 1) throw new InvalidArgumentException('Updated-by id must be a positive integer.');
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeEntryCommand.php';
/**
* Validates immutable time-entry corrections and logical voids.
* No database deletion is represented: adapters should persist the returned
* action in a transaction and retain the original entry for auditability.
*/
final class TimeEntryCorrectionCommand
{
public function __construct(private readonly ?TimeEntryCommand $entries = null)
{
}
/** @return array<string,mixed> */
public function validateCorrection(array $existing, array $changes): array
{
$id = $this->positiveId($existing['id'] ?? null);
$errors = $id === null ? ['id' => 'Time-entry ID must be a positive integer.'] : [];
if ($this->isVoided($existing)) $errors['voided'] = 'A voided time entry cannot be corrected.';
foreach (['jobcard_id', 'technician_id'] as $field) {
if (array_key_exists($field, $changes) && $this->positiveId($changes[$field]) !== $this->positiveId($existing[$field] ?? null)) $errors[$field] = "{$field} cannot be changed during correction.";
}
$allowed = ['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'];
$payload = $existing;
foreach ($allowed as $field) if (array_key_exists($field, $changes)) $payload[$field] = $changes[$field];
$validated = ($this->entries ?? new TimeEntryCommand())->validate($payload);
$errors = [...$validated['errors'], ...$errors];
$entry = [...$payload, ...$validated];
unset($entry['valid'], $entry['errors']);
$entry['id'] = $id;
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors];
}
/** @return array<string,mixed> */
public function validate(array $existing, array $changes = []): array
{
return $this->validateCorrection($existing, $changes);
}
/** @return array<string,mixed> */
public function validateForCorrection(array $existing, array $changes = []): array
{
return $this->validateCorrection($existing, $changes);
}
/** @return array<string,mixed> */
public function validateVoid(array $existing, array $input = []): array
{
$id = $this->positiveId($existing['id'] ?? null);
$errors = $id === null ? ['id' => 'Time-entry ID must be a positive integer.'] : [];
if ($this->isVoided($existing)) $errors['voided'] = 'Time entry is already voided.';
$reason = is_scalar($input['reason'] ?? null) ? trim((string)$input['reason']) : '';
if ($reason === '') $errors['reason'] = 'A void reason is required.';
elseif (mb_strlen($reason) > 1000) $errors['reason'] = 'Void reason must be 1000 characters or fewer.';
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors];
}
public function void(array $existing, array $input = []): array
{
return $this->validateVoid($existing, $input);
}
/** @return array<string,mixed> */
public function validateForVoid(array $existing, array $input = []): array
{
return $this->validateVoid($existing, $input);
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return filter_var(trim($value), FILTER_VALIDATE_INT) ?: null;
return null;
}
private function isVoided(array $entry): bool
{
$value = $entry['voided'] ?? false;
return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes'], true));
}
}
+32 -7
View File
@@ -19,18 +19,43 @@ final class NotificationQueue
$validation = ($this->records ?? new NotificationRecord())->validate($record);
if (!$validation['valid']) throw new InvalidArgumentException('Invalid notification: ' . implode(' ', $validation['errors']));
$title = is_scalar($record['title'] ?? null) ? trim((string)$record['title']) : '';
$body = is_scalar($record['body'] ?? null) ? trim((string)$record['body']) : '';
if ($title === '' || mb_strlen($title) > 190) throw new InvalidArgumentException('Notification title is required and must be 190 characters or fewer.');
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
$ids = [];
foreach ($validation['recipients'] as $email) {
$lookup->execute(['email' => $email]);
$userId = $lookup->fetchColumn();
if ($userId === false) continue;
$insert->execute(['user' => $userId, 'type' => $validation['type'], 'title' => $title, 'body' => $body === '' ? null : $body, 'dedup' => $validation['deduplication_key'], 'read_at' => $validation['is_read'] ? date('Y-m-d H:i:s') : null]);
$ids[] = (int)$pdo->lastInsertId();
try {
$pdo->beginTransaction();
foreach ($validation['recipients'] as $email) {
$dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email);
$lookup->execute(['email' => $email]);
$userId = $lookup->fetchColumn();
if ($userId === false) continue;
$insert->execute(['user' => $userId, 'type' => $dto['type'], 'title' => $dto['title'], 'body' => $dto['body'], 'dedup' => $dto['deduplication_key'], 'read_at' => $dto['is_read'] ? date('Y-m-d H:i:s') : null]);
$id = (int)$pdo->lastInsertId();
if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id;
}
$pdo->commit();
} catch (\Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $exception;
}
return $ids;
}
/** Map a multi-recipient notification into one queue DTO for one user. */
public function mapForUser(array $record, string $recipient): array
{
return ($this->records ?? new NotificationRecord())->toQueueDto($record, $recipient);
}
/** Mark a notification read only for the authenticated user's row. */
public function markRead(PDO $pdo, int|string $userId, array $command): bool
{
$user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$validation = ($this->records ?? new NotificationRecord())->validateMarkRead($command);
if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-read command.');
$stmt = $pdo->prepare('UPDATE notifications SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = :id AND user_id = :user');
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
return $stmt->rowCount() > 0;
}
}
+98 -1
View File
@@ -30,11 +30,75 @@ final class NotificationRecord
return [
'type' => $type,
'recipients' => $recipients,
'is_read' => $this->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
'is_read' => $this->normalizeReadState($record),
'deduplication_key' => $key,
];
}
/** Validate the command used by a user to mark one notification read. */
public function validateMarkRead(array $command): array
{
$value = $command['notification_id'] ?? $command['id'] ?? null;
$notificationId = $this->positiveInteger($value);
$errors = $notificationId === null
? ['notification_id' => 'Notification ID must be a positive integer.']
: [];
return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors];
}
/** Return one normalized queue DTO for a single recipient. */
public function toQueueDto(array $record, string $recipient): array
{
$normalized = $this->normalize($record);
$recipient = strtolower(trim($recipient));
if (!in_array($recipient, $normalized['recipients'], true)) {
throw new \InvalidArgumentException('Recipient is not present in the notification.');
}
return [
'type' => $normalized['type'],
'recipient' => $recipient,
'title' => is_scalar($record['title'] ?? null) ? trim((string) $record['title']) : '',
'body' => is_scalar($record['body'] ?? null) && trim((string) $record['body']) !== '' ? trim((string) $record['body']) : null,
'is_read' => $normalized['is_read'],
'deduplication_key' => $normalized['deduplication_key'],
];
}
/** @return list<array<string, mixed>> one DTO per normalized recipient */
public function toQueueDtos(array $record): array
{
$normalized = $this->normalize($record);
return array_map(fn (string $recipient): array => $this->toQueueDto($record, $recipient), $normalized['recipients']);
}
/** Build the assignment notification payload before queueing it. */
public function assignmentCreated(array $event): array
{
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
if ($jobcardId === null) throw new \InvalidArgumentException('Jobcard ID must be a positive integer.');
$reference = $this->text($event['jobcard_reference'] ?? $jobcardId);
$technician = $this->text($event['technician_name'] ?? null);
return $this->eventPayload($event, 'assignment_created', 'Jobcard assigned', sprintf('Jobcard %s was assigned%s.', $reference, $technician ? ' to ' . $technician : ''), 'assignment:' . $jobcardId);
}
/** Build the jobcard status notification payload before queueing it. */
public function statusChanged(array $event): array
{
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
$to = strtolower($this->text($event['to_status'] ?? null));
if ($jobcardId === null || $to === '') throw new \InvalidArgumentException('Jobcard ID and destination status are required.');
return $this->eventPayload($event, 'jobcard_status_changed', 'Jobcard status changed', sprintf('Jobcard %s status changed to %s.', $event['jobcard_reference'] ?? $jobcardId, $to), 'jobcard:' . $jobcardId . ':status:' . $to);
}
/** Build an SLA threshold notification payload before queueing it. */
public function slaThreshold(array $event): array
{
$clientId = $this->positiveInteger($event['client_id'] ?? null);
$threshold = strtolower($this->text($event['threshold'] ?? $event['level'] ?? null));
if ($clientId === null || $threshold === '') throw new \InvalidArgumentException('Client ID and SLA threshold are required.');
return $this->eventPayload($event, 'sla_threshold', 'SLA threshold reached', sprintf('Client %s has reached the %s SLA threshold.', $event['client_name'] ?? $clientId, $threshold), 'sla:' . $clientId . ':' . $threshold);
}
/** @return array<string, mixed> */
public function validate(array $record): array
{
@@ -91,4 +155,37 @@ final class NotificationRecord
};
return $value;
}
private function normalizeReadState(array $record): mixed
{
if (array_key_exists('read_at', $record)) {
if ($record['read_at'] === null) return false;
return is_scalar($record['read_at']) ? trim((string) $record['read_at']) !== '' : $record['read_at'];
}
$value = $record['is_read'] ?? $record['read'] ?? false;
if (is_string($value) && strtolower(trim($value)) === 'read') return true;
if (is_string($value) && strtolower(trim($value)) === 'unread') return false;
return $this->normalizeBoolean($value);
}
private function positiveInteger(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$trimmed = trim($value);
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
}
return null;
}
private function text(mixed $value): string
{
return is_scalar($value) ? trim((string) $value) : '';
}
private function eventPayload(array $event, string $type, string $title, string $body, string $deduplicationKey): array
{
return [...$this->normalize([...$event, 'type' => $type, 'deduplication_key' => $deduplicationKey]), 'title' => $title, 'body' => $body];
}
}
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientHistoryReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientHistoryReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reporting {
require_once __DIR__ . '/ReportFilters.php';
/** Deterministic, storage-agnostic filtering for a client's status history. */
final class ClientHistoryService
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function filter(array $rows, array|\ReportFilters|null $criteria = null): array
{
$filters = $criteria instanceof \ReportFilters ? $criteria : \ReportFilters::fromArray($criteria ?? []);
$result = [];
foreach ($rows as $row) {
if (!is_array($row) || !$filters->matches($row)) continue;
$result[] = $row;
}
usort($result, static fn (array $a, array $b): int => strcmp((string)($a['changed_at'] ?? $a['created_at'] ?? ''), (string)($b['changed_at'] ?? $b['created_at'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
return $result;
}
/** @return list<array<string,mixed>> */
public function forClient(array $rows, int|string $clientId, array $criteria = []): array
{
if (!is_int($clientId) && (!is_string($clientId) || preg_match('/^[1-9]\d*$/', trim($clientId)) !== 1)) throw new \InvalidArgumentException('Client ID must be a positive integer.');
$criteria['client_id'] = (int)$clientId;
return $this->filter($rows, $criteria);
}
/** @return list<array<string,mixed>> */
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
}
}
namespace {
if (!class_exists('ClientHistoryService', false)) class_alias('App\\Domain\\Reporting\\ClientHistoryService', 'ClientHistoryService');
}
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientJobcardReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientJobcardReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
+15
View File
@@ -44,6 +44,21 @@ final class CsvExporter
return $withBom ? self::UTF8_BOM . $csv : $csv;
}
/** Serialize an allow-listed report projection without exposing associative keys. */
public function exportRecords(array $records, bool $withBom = false): string
{
if ($records === []) return $withBom ? self::UTF8_BOM : '';
$headers = array_keys($records[0]);
$rows = [];
foreach ($records as $record) {
if (!is_array($record) || array_keys($record) !== $headers) {
throw new InvalidArgumentException('CSV report records must share the same ordered fields.');
}
$rows[] = array_values($record);
}
return $this->export($headers, $rows, $withBom);
}
/**
* @param list<mixed> $fields
*/
@@ -7,6 +7,12 @@ final class PrintReportRenderer
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$headerCount = count($headers);
foreach ($rows as $number => $row) {
if (!is_array($row) || count($row) !== $headerCount) {
throw new InvalidArgumentException(sprintf('Print report row %d must contain %d fields.', $number + 1, $headerCount));
}
}
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
final class ReportAudience
{
public const CLIENT = 'client';
public const INTERNAL = 'internal';
public static function validate(string $audience): string
{
if (!in_array($audience, [self::CLIENT, self::INTERNAL], true)) {
throw new InvalidArgumentException('Report audience must be client or internal.');
}
return $audience;
}
}
+5 -1
View File
@@ -25,9 +25,13 @@ final class ReportDataMapper
/** @return array<string,mixed> */
public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientActivity(array $record): array { return $this->clientFacing($record); }
public function clientActivity(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','hours','sla_hours'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientSla(array $record): array { return $this->allow($record, ['client_id','client_name','allocated_hours','used_hours','remaining_hours','usage_percentage','status']); }
/** @return array<string,mixed> */
public function internalSla(array $record): array { return $this->clientSla($record); }
private function allow(array $record, array $fields): array
{
+6 -2
View File
@@ -36,7 +36,7 @@ final class ReportFilters
if ($this->status !== null && (string)($row['status'] ?? $row['to_status'] ?? '') !== $this->status) return false;
if ($this->priority !== null && (string)($row['priority'] ?? '') !== $this->priority) return false;
if ($this->sla !== null && (string)($row['sla_status'] ?? $row['sla'] ?? '') !== $this->sla) return false;
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? '');
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? $row['start_date'] ?? '');
if ($this->dateFrom !== null && ($date === '' || substr($date, 0, 10) < $this->dateFrom)) return false;
if ($this->dateTo !== null && ($date === '' || substr($date, 0, 10) > $this->dateTo)) return false;
return true;
@@ -55,7 +55,11 @@ final class ReportFilters
private static function positiveInt(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return (int)$value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$trimmed = trim($value);
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
}
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
+30 -12
View File
@@ -1,29 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportAudience.php';
final class SlaReport
{
public function __construct(
private readonly ?ReportFilters $filters = null,
private readonly ?ReportDataMapper $mapper = null,
) {}
/** @param list<array<string, mixed>> $agreements
* @return list<array<string, mixed>>
*/
public function rows(array $agreements): array
public function rows(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$mapper = $this->mapper ?? new ReportDataMapper();
$rows = [];
foreach ($agreements as $agreement) {
if (!$filters->matches($agreement)) continue;
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) {
$used += max(0.0, (float)$hours);
}
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
$used = round($used, 2);
$remaining = round(max(0.0, $allocated - $used), 2);
$percentage = $allocated > 0
? round(($used / $allocated) * 100, 2)
: ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated
? 'exceeded'
: ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$rows[] = [
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$record = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
'allocated_hours' => $allocated,
@@ -32,8 +39,19 @@ final class SlaReport
'usage_percentage' => $percentage,
'status' => $status,
];
$rows[] = $audience === ReportAudience::CLIENT ? $mapper->clientSla($record) : $mapper->internalSla($record);
}
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
usort($rows, static fn(array $a, array $b): int => strcmp((string)$a['client_name'], (string)$b['client_name']) ?: ((int)$a['client_id'] <=> (int)$b['client_id']));
return $rows;
}
public function build(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
public function query(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
}
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class TechnicianActivityReport implements ReportQuery
{
@@ -10,10 +11,13 @@ final class TechnicianActivityReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$key = (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
$key = $audience === 'client'
? 'client:' . (string)(int)($row['client_id'] ?? 0)
: (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
if (!isset($totals[$key])) $totals[$key] = ['technician_id' => (int)($row['technician_id'] ?? 0), 'technician_name' => (string)($row['technician_name'] ?? ''), 'client_id' => (int)($row['client_id'] ?? 0), 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
$hours = max(0.0, (float)($row['hours'] ?? 0)); $totals[$key]['hours'] += $hours;
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
@@ -21,7 +25,7 @@ final class TechnicianActivityReport implements ReportQuery
$result = array_values($totals);
foreach ($result as &$item) { $item['hours'] = round($item['hours'], 2); $item['sla_hours'] = round($item['sla_hours'], 2); if ($audience === 'client') $item = $mapper->clientActivity($item); }
unset($item);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)));
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)) ?: strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)($a['client_id'] ?? 0) <=> (int)($b['client_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
require_once __DIR__ . '/PermissionMatrix.php';
require_once __DIR__ . '/RoleRecord.php';
/** PDO-independent contracts for custom-role permission assignment. */
final class RolePermissionService
{
public function __construct(
private readonly ?RoleRecord $roles = null,
private readonly ?PermissionMatrix $permissions = null,
) {
}
/** @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>} */
public function validate(array $role, array $selected, array $available = []): array
{
return $this->validateAssignment($role, $selected, $available);
}
/**
* Validate and normalize a role's selected permissions. When an available
* list is supplied, selections outside that list are rejected rather than
* silently discarded.
*
* @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>}
*/
public function validateAssignment(array $role, array $selected, array $available = []): array
{
$roleId = $this->positiveId($role['id'] ?? $role['role_id'] ?? null);
$normalized = ($this->permissions ?? new PermissionMatrix())->normalize($selected);
$errors = [];
if ($roleId === null) $errors['role_id'] = 'Role ID must be a positive integer.';
if (($this->roles ?? new RoleRecord())->isAdministrator($role)) {
$errors['role'] = 'The protected Administrator role permissions cannot be changed.';
}
if ($available !== []) {
$allowed = ($this->permissions ?? new PermissionMatrix())->normalize($available);
$unknown = array_values(array_diff($normalized, $allowed));
if ($unknown !== []) {
$errors['permissions'] = 'Unknown permissions cannot be assigned: ' . implode(', ', $unknown) . '.';
}
}
return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** Alias matching controller command terminology. */
public function validateForAssignment(array $role, array $selected, array $available = []): array
{
return $this->validateAssignment($role, $selected, $available);
}
/**
* Validate a role edit and its optional permission set in one safe result.
* Administrator cannot be renamed or have permissions changed.
*
* @return array<string, mixed>
*/
public function validateForEdit(int $id, array $input, array $available = []): array
{
$record = $this->roles ?? new RoleRecord();
$result = $record->validate($input);
$result['id'] = $id;
if ($id < 1) $result['errors']['id'] = 'Role ID must be a positive integer.';
$current = ['id' => $id, 'name' => $id === 1 ? 'Administrator' : ($input['current_name'] ?? ($input['name'] ?? null))];
if (array_key_exists('current_name', $input)) {
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
}
if (array_key_exists('permissions', $input)) {
$assignment = $this->validateAssignment(['id' => $id, 'name' => $current['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
$result['permissions'] = $assignment['permissions'];
$result['errors'] = [...$result['errors'], ...$assignment['errors']];
}
$result['valid'] = $result['errors'] === [];
return $result;
}
public function canAssignPermissions(array $role): bool
{
return !($this->roles ?? new RoleRecord())->isAdministrator($role);
}
public function assertCanAssignPermissions(array $role): void
{
($this->roles ?? new RoleRecord())->assertCanChangePermissions($role);
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
require_once __DIR__ . '/PasswordPolicy.php';
require_once __DIR__ . '/UserRecord.php';
/**
* PDO-independent validation contracts for user administration actions.
* Persistence and authorization middleware remain the caller's responsibility.
*/
final class UserAdminService
{
public function __construct(
private readonly ?UserRecord $users = null,
private readonly ?PasswordPolicy $passwordPolicy = null,
) {
}
/** @return array<string, mixed> */
public function validate(array $input, array $existingUsers = [], ?int $currentId = null): array
{
$result = ($this->users ?? new UserRecord())->validate($input);
$email = $result['email'] ?? '';
if ($currentId !== null && is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $currentId)) {
$result['errors']['email'] = 'Email address is already in use.';
}
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingUsers = []): array
{
$errors = [];
if ($id < 1) {
$errors['id'] = 'User ID must be a positive integer.';
}
$result = ($this->users ?? new UserRecord())->validate($input);
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
if ($this->isProtectedAdministrator($existing) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== (int)($existing['role_id'] ?? 1)) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
$email = $result['email'] ?? '';
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
$result['errors']['email'] = 'Email address is already in use.';
}
$result['id'] = $id;
$result['errors'] = [...$errors, ...$result['errors']];
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function validateDeactivate(array $user): array
{
return $this->validateTransition($user, true, false, 'deactivated');
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function validateReactivate(array $user): array
{
return $this->validateTransition($user, false, true, 'reactivated');
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function deactivate(array $user): array
{
return $this->validateDeactivate($user);
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function reactivate(array $user): array
{
return $this->validateReactivate($user);
}
/** Validate reset input without ever returning the plaintext password. */
/** @return array{valid: bool, errors: array<string, string>} */
public function validatePasswordReset(array $user, mixed $password = null): array
{
$payload = $password === null && array_key_exists('password', $user);
if ($payload) $password = $user['password'];
$errors = [];
if (!$payload && $this->positiveId($user['id'] ?? null) === null) {
$errors['id'] = 'User ID must be a positive integer.';
}
$check = ($this->passwordPolicy ?? new PasswordPolicy())->validateReset($password);
if (!$check['valid']) {
$errors['password'] = 'Password requires: ' . implode(', ', $check['errors']) . '.';
}
return ['valid' => $errors === [], 'errors' => $errors];
}
/** Aliases useful to controllers accepting a reset command payload. */
/** @return array{valid: bool, errors: array<string, string>} */
public function validateReset(array $user, mixed $password = null): array
{
return $this->validatePasswordReset($user, $password);
}
/** @return array{valid: bool, errors: array<string, string>} */
public function validateResetPassword(array $user, mixed $password = null): array
{
return $this->validatePasswordReset($user, $password);
}
/** @return array<string, mixed> */
public function display(array $user): array
{
return ($this->users ?? new UserRecord())->display($user);
}
/** @return array<string, mixed> */
public function toDisplay(array $user): array
{
return $this->display($user);
}
public function isProtectedAdministrator(array $user): bool
{
if (isset($user['role_id']) && (int)$user['role_id'] === 1) return true;
$role = $user['role_name'] ?? $user['role'] ?? null;
return is_scalar($role) && strtolower(trim((string) $role)) === 'administrator';
}
private function validateTransition(array $user, bool $from, bool $to, string $action): array
{
$id = $this->positiveId($user['id'] ?? null);
$active = $this->asBool($user['is_active'] ?? $user['active'] ?? null);
$errors = [];
if ($id === null) {
$errors['id'] = 'User ID must be a positive integer.';
}
if ($this->isProtectedAdministrator($user)) {
$errors['role'] = 'The protected Administrator account cannot be deactivated.';
} elseif ($active !== $from) {
$errors['is_active'] = "Only {$this->stateName($from)} users can be {$action}.";
}
return ['valid' => $errors === [], 'id' => $id ?? 0, 'is_active' => $to, 'errors' => $errors];
}
private function hasDuplicateEmail(string $email, array $rows, int $currentId): bool
{
foreach ($rows as $row) {
if (!is_array($row) || $this->positiveId($row['id'] ?? null) === $currentId) continue;
$other = $row['email'] ?? null;
if (is_scalar($other) && strtolower(trim((string) $other)) === $email) return true;
}
return false;
}
private function stateName(bool $active): string
{
return $active ? 'active' : 'inactive';
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
private function asBool(mixed $value): ?bool
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) {
$value = strtolower(trim($value));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true;
if (in_array($value, ['0', 'false', 'no', 'off'], true)) return false;
}
return null;
}
}
+13
View File
@@ -67,6 +67,19 @@ Run this checklist against a production-like deployment over HTTPS with a fresh
- [ ] Record restore duration, backup timestamp, row/data spot checks and any missing items.
- [ ] Confirm the live system was not modified by restore testing and securely remove the temporary restored copy when approved.
## Explicit security acceptance cases
Record the request URL/route, authenticated role, test fixture IDs, expected response, observed response, and evidence for each case. Use separate Technician accounts and at least two clients/jobcards so an ID change cannot accidentally target the same tenant.
- [ ] **SEC-01 — Technician cross-client IDOR (read):** Technician A requests Technician B's jobcard URL and an attachment URL belonging to that jobcard. Both requests return the same not-found/denied behavior as an unknown ID, and no client name, jobcard details, attachment bytes or metadata are disclosed.
- [ ] **SEC-02 — Technician cross-client IDOR (write):** Technician A submits status, notes, assignment or time-entry payloads with Technician B's jobcard ID. CSRF-valid requests are still denied by authorization, and the target jobcard, assignments and time entries remain unchanged.
- [ ] **SEC-03 — Own-technician time isolation:** Create one assigned jobcard with time recorded by Technician A and Technician B. Technician A's report/UI/export contains only A's hours; it does not include B's hours or another client's totals. Repeat with a direct report URL and CSV export.
- [ ] **SEC-04 — Credential canonical storage:** Create a credential containing a unique canary secret. The database row has `secret_ciphertext` and no plaintext `secret` field/value; normal views show a mask; only an authorized reveal returns the secret once, with no-store headers and an audit event. A different client's credential ID cannot be revealed.
- [ ] **SEC-05 — Attachment safe metadata:** Attempt traversal names (`../x.pdf`), executable/double extensions (`invoice.php.jpg`), MIME mismatches, oversized files, and client-visible without explicit approval. Each is rejected before storage. A valid image/PDF is stored under a generated server name, served with its validated MIME and `X-Content-Type-Options: nosniff`, and remains inaccessible through a different jobcard/client ID.
- [ ] **SEC-06 — Healthcheck schema contract:** Run `php bin/healthcheck.php` with valid configuration and confirm every current schema table is probed, output contains statuses only, and no password, APP_KEY, database DSN, SQL exception, or secret value is printed. Remove/rename one required table in a disposable database and confirm a non-zero failure.
- [ ] **SEC-07 — CSRF route assumptions:** For login, logout, client create/edit/contact, jobcard create/update, time, assignment, attachment, credential and SLA POSTs, submit with a missing token and a wrong token. Every request is rejected before mutation (HTTP 419 or the documented equivalent); the valid-token control succeeds. GET requests do not mutate state.
- [ ] **SEC-08 — Migration coverage and restore:** Restore a pre-current-schema backup to an isolated database, run `database/upgrade.sql` once and a second time, and confirm feature tables/columns, permissions and the one-SLA-per-client constraint are present. Resolve/record duplicate SLA rows before the unique constraint step; rerun healthcheck and verify representative clients, jobcards, time entries, credentials and attachments.
## Sign-off
- Environment/version: ______________________________
+51
View File
@@ -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");
+44
View File
@@ -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");
+30
View File
@@ -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, '&lt;script&gt;')) {
throw new RuntimeException('PDF-ready HTML must escape report content.');
}
printf("Report workflow tests: 5 passed\n");
+133
View File
@@ -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");
+79
View File
@@ -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");