Files
JobcardSystem/tests/SecurityRegressionTest.php
T

134 lines
8.7 KiB
PHP

<?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(
str_contains($frontController, 'array_unshift($hoursPredicates') && str_contains($frontController, 'te.technician_id = ?'),
'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);