Add system status displays for Minio and Redis, and enhance backup configuration

This commit is contained in:
Jeppe Bundgaard
2026-07-13 10:08:00 +02:00
parent fa1ade555f
commit 012e5366ba
55 changed files with 7968 additions and 706 deletions
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists only visible active departments with public time bookings enabled', function (): void {
api_test_covers('GET /department/timebookings/departments/public', 'happy');
$enabledDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Enabled ' . uniqid('', false),
'description' => 'Enabled booking department address',
'visible' => 1,
'archived' => 0,
'latitude' => 55.1,
'longitude' => 12.1,
'order_priority' => 7,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$enabledDepartment['id'], true);
$disabledDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Disabled ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
$missingVariableDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Missing Variable ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
$legacyTruthyDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Legacy Truthy ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentVariable(
(int)$legacyTruthyDepartment['id'],
'bookingsystem_time_based_enabled',
'1'
);
$hiddenDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Hidden ' . uniqid('', false),
'visible' => 0,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$hiddenDepartment['id'], true);
$archivedDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Archived ' . uniqid('', false),
'visible' => 1,
'archived' => 1,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$archivedDepartment['id'], true);
$response = api_client()->get('/department/timebookings/departments/public');
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$departmentsById = [];
foreach ($response->data() as $department) {
$departmentsById[(int)($department['id'] ?? 0)] = $department;
}
expect($departmentsById)
->toHaveKey((int)$enabledDepartment['id'])
->not->toHaveKey((int)$disabledDepartment['id'])
->not->toHaveKey((int)$missingVariableDepartment['id'])
->not->toHaveKey((int)$legacyTruthyDepartment['id'])
->not->toHaveKey((int)$hiddenDepartment['id'])
->not->toHaveKey((int)$archivedDepartment['id']);
$returnedEnabledDepartment = $departmentsById[(int)$enabledDepartment['id']];
expect($returnedEnabledDepartment)
->toHaveKey('name')
->toHaveKey('description', 'Enabled booking department address')
->toHaveKey('address', 'Enabled booking department address')
->toHaveKey('time_booking_enabled', true)
->not->toHaveKey('slack_webhook')
->not->toHaveKey('custom_pricing_only')
->not->toHaveKey('variables')
->not->toHaveKey('bookingsystem_time_based_enabled');
});
it('returns an empty public time-booking department list when no matching department is enabled', function (): void {
api_test_covers('GET /department/timebookings/departments/public', 'empty');
$uniqueName = 'Public Time Booking Empty ' . uniqid('', false);
$disabledDepartment = api_fixtures()->createDepartment([
'name' => $uniqueName,
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
$response = api_client()->get(
'/department/timebookings/departments/public?search=' . rawurlencode($uniqueName)
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())->toBe([]);
});
it('rejects public time-booking detail requests for disabled departments', function (): void {
api_test_covers('GET /department/timebookings/types/public', 'disabled');
$disabledDepartment = api_fixtures()->createDepartment([
'name' => 'Disabled Public Time Booking Detail ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
api_client()->get('/department/timebookings/types/public?id=' . (int)$disabledDepartment['id'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department time bookings are not enabled');
});
+158 -14
View File
@@ -218,10 +218,16 @@ it('lists chauffeur grants across customers for superusers', function (): void {
]);
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
$firstVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$firstCustomer['customer_number'],
'type' => 1,
'reg' => 'ab12345',
]);
$firstGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST', 'SUBUSERS_LIST']
['VEHICLES_LIST', 'SUBUSERS_LIST', 'SELFSERVE_LIST', 'SELFSERVE_ADD'],
['assigned_vehicle_id' => (int)$firstVehicle['id']]
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$secondSubuser['id'],
@@ -247,23 +253,23 @@ it('lists chauffeur grants across customers for superusers', function (): void {
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
));
expect($rows)->toHaveCount(2);
expect($rows)->toHaveCount(3);
$bySubuserId = [];
$byGrantId = [];
foreach ($rows as $row) {
$bySubuserId[(int)$row['id']] = $row;
$byGrantId[(int)$row['grant_id']] = $row;
}
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
->toContain($firstGrantId)
->toContain($sharedGrantId);
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$firstGrantId]['id'])->toBe((int)$firstSubuser['id']);
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['assigned_vehicle_id'])->toBe((int)$firstVehicle['id']);
expect($byGrantId[$firstGrantId]['assigned_vehicle_reg'])->toBe('AB12345');
expect($byGrantId[$firstGrantId]['dognvask_enabled'])->toBeTrue();
expect($byGrantId[$sharedGrantId]['id'])->toBe((int)$firstSubuser['id']);
expect($byGrantId[$sharedGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$sharedGrantId]['dognvask_enabled'])->toBeFalse();
expect($byGrantId[$secondGrantId]['id'])->toBe((int)$secondSubuser['id']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -483,6 +489,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$targetCustomer['customer_number'],
'type' => 1,
'reg' => 'scope123',
]);
$otherVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$otherCustomer['customer_number'],
'type' => 1,
'reg' => 'other123',
]);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
@@ -501,9 +517,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
'assigned_vehicle_id' => (int)$targetVehicle['id'],
],
$session['headers']
);
$mismatchedVehicle = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
['assigned_vehicle_id' => (int)$otherVehicle['id']],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
@@ -515,6 +538,10 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$mismatchedVehicle
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
$crossCustomer
->assertStatus(404)
->assertEnvelope()
@@ -523,6 +550,123 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
expect($update->data()['grant']['assigned_vehicle_id'] ?? null)->toBe((int)$targetVehicle['id']);
expect($update->data()['subuser']['assigned_vehicle_reg'] ?? null)->toBe('SCOPE123');
});
it('lets superusers edit chauffeur account details and set passwords', function (): void {
api_test_covers('PATCH /superuser/subusers/{subuser_id}', 'happy');
api_test_covers('POST /superuser/subusers/{subuser_id}/password', 'happy');
$session = api_fixtures()->createUserSession(['edit_subusers']);
$subuser = api_fixtures()->createSubuser([
'password_plaintext' => null,
'name' => 'Admin Managed Driver',
'email' => null,
'phone_country_code' => 45,
'phone' => 73123456,
]);
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
$subuserObject->getObjectProperties();
$setupToken = $subuserObject->generateSetupToken();
try {
$profile = api_client()->request(
'PATCH',
'/superuser/subusers/' . $subuser['id'],
[
'name' => 'Admin Updated Driver',
'email' => 'admin.updated.driver@example.com',
'phone_country_code' => 46,
'phone' => 73123457,
],
$session['headers']
);
$password = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/password',
['password' => 'ValidPass123'],
$session['headers']
);
$profile
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$password
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($profile->data()['subuser']['name'] ?? null)->toBe('Admin Updated Driver');
expect($profile->data()['subuser']['email'] ?? null)->toBe('admin.updated.driver@example.com');
expect($profile->data()['subuser']['phone_country_code'] ?? null)->toBe(46);
expect($profile->data()['subuser']['phone'] ?? null)->toBe(73123457);
expect($password->data()['subuser']['setup_required'] ?? null)->toBeFalse();
expect((new \objects\subusers_o())->getSubuserBySetupToken($setupToken))->toBeNull();
$row = api_fixtures()->fetchRowById('subusers', (int)$subuser['id']);
expect(password_verify('ValidPass123', (string)($row['password'] ?? '')))->toBeTrue();
} finally {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
});
it('creates direct chauffeur login links scoped to a selected customer grant', function (): void {
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'happy');
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'failure');
$session = api_fixtures()->createUserSession(['edit_subusers', 'SUPERUSER_INTIMIDATE']);
$firstCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Alpha']);
$secondCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Beta']);
$subuser = api_fixtures()->createSubuser(['name' => 'Direct Login Driver']);
api_fixtures()->grantSubuser(
(int)$subuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST']
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$subuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$ambiguous = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/login-link',
[],
$session['headers']
);
$direct = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/login-link',
['grant_id' => $secondGrantId],
$session['headers']
);
$ambiguous
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number or grant id is required for drivers with multiple customer grants');
$direct
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($direct->data()['customer_number'] ?? null)->toBe((int)$secondCustomer['customer_number']);
expect($direct->data()['login_path'] ?? null)->toBeString();
$parts = parse_url((string)$direct->data()['login_path']);
parse_str((string)($parts['query'] ?? ''), $query);
expect($parts['path'] ?? null)->toBe('/login/qr');
expect($query['type'] ?? null)->toBe('subuser');
expect((int)($query['customer_number'] ?? 0))->toBe((int)$secondCustomer['customer_number']);
expect($query['token'] ?? null)->toBeString();
$resolved = (new \objects\subusers_o())->getSubuserBySessionToken((string)$query['token']);
expect($resolved)->not->toBeNull();
expect((int)$resolved->id)->toBe((int)$subuser['id']);
(new \objects\subusers_o())->invalidateSessionToken((string)$query['token']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
@@ -148,6 +148,42 @@ final class ApiFixtures
return ['id' => $departmentId];
}
public function setDepartmentVariable(int $departmentId, string $variable, mixed $value): void
{
if ($departmentId <= 0) {
throw new RuntimeException('Department variable fixtures require a positive department id.');
}
$variable = trim($variable);
if ($variable === '') {
throw new RuntimeException('Department variable fixtures require a variable name.');
}
$conditions = [
'department_id' => $departmentId,
'variable' => $variable,
];
$this->deleteWhereIfPossible('department_variables', $conditions);
$this->cleanupDeleteWhere('department_variables', $conditions);
$variableId = $this->insertRowWithExistingColumns('department_variables', [
'department_id' => $departmentId,
'variable' => $variable,
'value' => (string)$value,
]);
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
}
public function setDepartmentTimeBookingsEnabled(int $departmentId, bool $enabled): void
{
$this->setDepartmentVariable(
$departmentId,
'bookingsystem_time_based_enabled',
$enabled ? 'true' : 'false'
);
}
public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void
{
if ($departmentId <= 0) {
@@ -1029,16 +1065,17 @@ final class ApiFixtures
];
}
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions): int
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions, array $attributes = []): int
{
$grantId = $this->insertRow('subuser_grants', [
'billing_customer_number' => $customerNumber,
'subuser' => $subuserId,
'enabled' => 1,
'note' => 'API test grant',
'assigned_vehicle_id' => $attributes['assigned_vehicle_id'] ?? null,
'enabled' => $attributes['enabled'] ?? 1,
'note' => $attributes['note'] ?? 'API test grant',
'permissions' => json_encode(array_values($permissions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => $this->now(),
'updated_at' => $this->now(),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => null,
]);
@@ -95,6 +95,110 @@ CREATE TABLE IF NOT EXISTS `logs` (
KEY `idx_logs_action` (`action`),
KEY `idx_logs_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_firewall_rules' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_firewall_rules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`action` VARCHAR(16) NOT NULL,
`target_type` VARCHAR(32) NOT NULL,
`target_value` VARCHAR(255) NOT NULL,
`route_pattern` VARCHAR(255) NULL,
`priority` INT NOT NULL DEFAULT 100,
`reason` TEXT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`expires_at` DATETIME NULL,
`metadata_json` LONGTEXT NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_security_firewall_rules_active` (`enabled`, `deleted_at`, `expires_at`),
KEY `idx_security_firewall_rules_target` (`target_type`, `target_value`),
KEY `idx_security_firewall_rules_priority` (`priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_policy_rules' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_policy_rules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_key` VARCHAR(64) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`threshold_count` INT NOT NULL,
`window_seconds` INT NOT NULL,
`mode` VARCHAR(16) NOT NULL DEFAULT 'observe',
`exempt_permission_nodes_json` LONGTEXT NULL,
`updated_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_security_policy_rules_key` (`rule_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_policy_events' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_policy_events` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_key` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_key` VARCHAR(191) NOT NULL,
`route_path` VARCHAR(255) NULL,
`route_template` VARCHAR(255) NULL,
`method` VARCHAR(16) NULL,
`source_ip` VARCHAR(64) NULL,
`customer_number` INT NULL,
`user_id` INT NULL,
`subuser_id` INT NULL,
`metadata_json` LONGTEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_security_policy_events_window` (`rule_key`, `subject_type`, `subject_key`, `created_at`),
KEY `idx_security_policy_events_created` (`created_at`),
KEY `idx_security_policy_events_customer` (`customer_number`, `created_at`),
KEY `idx_security_policy_events_ip` (`source_ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_incidents' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_incidents` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`incident_key` VARCHAR(191) NOT NULL,
`type` VARCHAR(64) NOT NULL,
`severity` VARCHAR(16) NOT NULL DEFAULT 'medium',
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
`title` VARCHAR(255) NOT NULL,
`source_ip` VARCHAR(64) NULL,
`customer_number` INT NULL,
`user_id` INT NULL,
`subuser_id` INT NULL,
`route_path` VARCHAR(255) NULL,
`route_template` VARCHAR(255) NULL,
`method` VARCHAR(16) NULL,
`related_rule_id` BIGINT UNSIGNED NULL,
`related_firewall_rule_id` BIGINT UNSIGNED NULL,
`occurrence_count` INT NOT NULL DEFAULT 1,
`metadata_json` LONGTEXT NULL,
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`resolved_by` INT NULL,
`resolved_at` DATETIME NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_security_incidents_key` (`incident_key`),
KEY `idx_security_incidents_status_seen` (`status`, `last_seen_at`),
KEY `idx_security_incidents_type_seen` (`type`, `last_seen_at`),
KEY `idx_security_incidents_customer_seen` (`customer_number`, `last_seen_at`),
KEY `idx_security_incidents_ip_seen` (`source_ip`, `last_seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_incident_notes' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_incident_notes` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`incident_id` BIGINT UNSIGNED NOT NULL,
`note` TEXT NOT NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_security_incident_notes_incident` (`incident_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'departments' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `departments` (
@@ -732,6 +836,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`billing_customer_number` INT NOT NULL,
`subuser` INT NOT NULL,
`assigned_vehicle_id` INT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`note` TEXT NULL,
`permissions` LONGTEXT NULL,
@@ -740,6 +845,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_subuser_grants_subuser` (`subuser`),
KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`),
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
@@ -0,0 +1,78 @@
<?php
it('defines durable backup schema tables for records, components, jobs, and restore audit', function (): void {
$content = file_get_contents(app_path('classes/backup_schema_bootstrap.php'));
expect($content)->toContain('CREATE TABLE IF NOT EXISTS backup_records')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_components')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_jobs')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_restore_audit')
->and($content)->toContain('manifest_sha256')
->and($content)->toContain('encryption_key_id');
});
it('hardens database dumps by keeping passwords out of the mysqldump command', function (): void {
$content = file_get_contents(app_path('classes/db.php'));
expect($content)->toContain('MYSQL_PWD')
->and($content)->toContain('--single-transaction')
->and($content)->toContain('--routines')
->and($content)->toContain('--triggers')
->and($content)->toContain('--events')
->and($content)->not->toContain('--password=$pass');
});
it('uses encrypted component backups and never stores raw environment dumps', function (): void {
$content = file_get_contents(app_path('classes/backup_store.php'));
expect($content)->toContain('AES-256-GCM')
->and($content)->toContain('BACKUP_ENCRYPTION_KEY_V1')
->and($content)->toContain('required_runtime_config_keys')
->and($content)->toContain('createObjectBucketComponent')
->and($content)->not->toContain('json_encode($_ENV)')
->and($content)->not->toContain("exec('zip -r");
});
it('normalizes string boolean config values before destructive restore gates', function (): void {
$content = file_get_contents(app_path('classes/backup_store.php'));
expect($content)->toContain("['1', 'true', 'yes', 'on']")
->and($content)->toContain("['0', 'false', 'no', 'off', '']")
->and($content)->toContain('Backup system is disabled in backup configuration.')
->and($content)->toContain('Direct production restore is disabled in backup configuration.');
});
it('wires backup job, verification, restore preview, restore, and audit routes', function (): void {
$content = file_get_contents(app_path('routes/moduleBackupsRoute.php'));
expect($content)->toContain('/modules/backup/jobs/{id}')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/verify')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore')
->and($content)->toContain('/modules/backup/restore-audit')
->and($content)->toContain('modules_backup_restore')
->and($content)->toContain('Subuser sessions cannot manage backup disaster recovery.');
});
it('registers hourly backup enqueue, worker, and retention prune cron tasks', function (): void {
$content = file_get_contents(app_path('modules/backups/cron/tasks.php'));
$cron = file_get_contents(app_path('cron/Cron.php'));
expect($content)->toContain("'seconds' => 3600")
->and($content)->toContain('backups.process_jobs')
->and($content)->toContain('backups.prune_retention')
->and($cron)->toContain('processBackupJobs')
->and($cron)->toContain('pruneBackupRetention');
});
it('documents backup disaster recovery APIs and config variables in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('/modules/backup/jobs/{id}:')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview:')
->and($content)->toContain('BackupRestoreRequest')
->and($content)->toContain('BackupRestoreAuditListResponse')
->and($content)->toContain('retention_recent_hours')
->and($content)->toContain('verification_required')
->and($content)->toContain('restore_enabled');
});
@@ -7,9 +7,11 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(20);
expect($definitions)->toHaveCount(22);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'backups.process_jobs',
'backups.prune_retention',
'economic.transfer_queue',
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
@@ -0,0 +1,39 @@
<?php
it('wires cron workers through schema, CLI, scheduler, and superuser routes', function (): void {
$schema = file_get_contents(app_path('classes/cron_schema_bootstrap.php'));
$worker = file_get_contents(app_path('classes/cron_worker.php'));
$scheduler = file_get_contents(app_path('classes/cron_scheduler.php'));
$cli = file_get_contents(app_path('cli.php'));
$route = file_get_contents(app_path('routes/cronRoute.php'));
$manager = file_get_contents(app_path('classes/release_manager.php'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS cron_worker_state');
expect($schema)->toContain('last_heartbeat_at');
expect($schema)->toContain('last_stale_run_count');
expect($worker)->toContain('CRON_WORKER_POLL_SECONDS');
expect($worker)->toContain('CRON_WORKER_HEARTBEAT_SECONDS');
expect($worker)->toContain('CRON_WORKER_RELEASE_TARGET_ID');
expect($worker)->toContain('markExpiredRunningRuns');
expect($worker)->toContain('runDue($this->source)');
expect($scheduler)->toContain('function markExpiredRunningRuns');
expect($scheduler)->toContain("r.status = 'timed_out'");
expect($scheduler)->toContain('s.current_run_id = NULL');
expect($cli)->toContain("case 'cron-worker'");
expect($cli)->toContain('new \\classes\\cron_worker()');
expect($route)->toContain('/superuser/cron/workers');
expect($route)->toContain('/superuser/cron/workers/deploy');
expect($route)->toContain('superuser_cron_view');
expect($route)->toContain('superuser_cron_manage');
expect($route)->toContain('superuser_coolify_manage');
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
expect($manager)->toContain('cron_worker_deploy_failed');
expect($manager)->toContain('auto_deploy = 0');
});
@@ -375,6 +375,73 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
expect($payload)->not->toHaveKey('is_static');
});
it('creates private Coolify application payloads for cron workers', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'cron',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'auto_deploy' => 0,
], [
'coolify_service_name' => 'release-internal-cron-worker',
'coolify_project_uuid' => 'project-internal',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_build_pack' => 'dockerfile',
'coolify_deploy_now' => true,
'coolify_start_command' => 'php index.php run cron-worker',
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-node3',
]);
expect($payload['name'])->toBe('release-internal-cron-worker');
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($payload['start_command'])->toBe('php index.php run cron-worker');
expect($payload['is_auto_deploy_enabled'])->toBeFalse();
expect($payload)->not->toHaveKey('domains');
expect($payload)->not->toHaveKey('is_force_https_enabled');
});
it('derives cron worker deployment context from the API target without public routing', function (): void {
$manager = new release_manager();
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
$contextMethod->setAccessible(true);
$context = $contextMethod->invoke($manager, [
'id' => 17,
'channel_id' => 3,
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_environment_name' => 'production',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'manual_endpoint_host' => 'manual.example.test',
]),
], null, '5555555555555555555555555555555555555555', 41);
expect($context['coolify_auto_create'])->toBeTrue();
expect($context['coolify_resource_type'])->toBe('application');
expect($context['coolify_build_pack'])->toBe('dockerfile');
expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
expect($context['coolify_enable_ssl'])->toBeFalse();
expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($context)->not->toHaveKey('coolify_public_url');
expect($context)->not->toHaveKey('manual_endpoint_host');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
@@ -658,6 +725,57 @@ it('forces selected API commit into generated Coolify runtime env keys', functio
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
});
it('builds cron worker runtime environment from API runtime keys and cron context', function (): void {
$keys = ['CONFIG_DB_HOST', 'CRON_WORKER_NAME'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = getenv($key);
}
try {
putenv('CONFIG_DB_HOST=db.example.test');
$_ENV['CONFIG_DB_HOST'] = 'db.example.test';
$_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
putenv('CRON_WORKER_NAME=ignored-runtime-name');
$_ENV['CRON_WORKER_NAME'] = 'ignored-runtime-name';
$_SERVER['CRON_WORKER_NAME'] = 'ignored-runtime-name';
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '4444444444444444444444444444444444444444';
$env = $runtimeEnv->invoke($manager, [
'app' => 'cron',
'commit_sha' => $selectedCommit,
], [
'coolify_env' => [
'CRON_WORKER_NAME' => 'release-internal-cron-worker',
'CRON_WORKER_SOURCE' => 'coolify_worker',
],
]);
expect($env['USE_ENV'])->toBe('true');
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
} finally {
foreach ($previous as $key => $value) {
if ($value === false) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
});
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
$manager = new release_manager();
$gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');
@@ -1444,6 +1562,32 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBe('https://gateway.example.test/beta/frontend');
$cron = $endpoint->invoke($manager, [
'app' => 'cron',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
'coolify_public_url' => 'https://api-v2.truckwash.io',
]),
]);
expect($cron)->toMatchArray([
'status' => 'pending',
'host' => null,
'url' => null,
'source' => 'private_worker',
]);
expect($publicUrl->invoke($manager, ['app' => 'cron', 'channel_slug' => 'beta'], [
'public_gateway_host' => 'gateway.example.test',
]))->toBeNull();
expect($targetPublicBaseUrl->invoke($manager, [
'app' => 'cron',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBeNull();
});
it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
@@ -1634,3 +1778,56 @@ it('restricts release gate fetches to Truckwash release hosts and relative paths
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
->toThrow(RuntimeException::class, 'relative');
});
it('normalizes Coolify application list payloads for release cleanup previews', function (): void {
$manager = new release_manager();
$payloadRows = new ReflectionMethod(release_manager::class, 'payloadRows');
expect($payloadRows->invoke($manager, [
'applications' => [
['uuid' => 'app-1', 'name' => 'release-canary-api'],
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
],
]))->toBe([
['uuid' => 'app-1', 'name' => 'release-canary-api'],
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
]);
});
it('keeps Coolify cleanup selection hashes tied to action and resource identity', function (): void {
$manager = new release_manager();
$hash = new ReflectionMethod(release_manager::class, 'coolifyCleanupSelectionHash');
$phrase = new ReflectionMethod(release_manager::class, 'coolifyCleanupConfirmationPhrase');
$matches = new ReflectionMethod(release_manager::class, 'coolifyCleanupReferenceMatchesPolicy');
$policy = [
'instance_id' => 3,
'channel_id' => 2,
'channel_slug' => 'canary',
'app' => 'api',
'resource_type' => 'application',
'action' => 'delete',
];
$candidate = [
'instance_id' => 3,
'type' => 'application',
'uuid' => 'old-api-canary-app',
'action' => 'delete',
];
$reference = [
'apps' => ['api' => 'api'],
'channels' => [2 => 2],
'resource_types' => ['application' => 'application'],
];
$deleteHash = $hash->invoke($manager, $policy, [$candidate]);
$stopHash = $hash->invoke($manager, array_replace($policy, ['action' => 'stop']), [
array_replace($candidate, ['action' => 'stop']),
]);
expect($matches->invoke($manager, $reference, $policy))->toBeTrue()
->and($matches->invoke($manager, $reference, array_replace($policy, ['app' => 'frontend'])))->toBeFalse()
->and($matches->invoke($manager, $reference, array_replace($policy, ['channel_id' => 99])))->toBeFalse()
->and($deleteHash)->not->toBe($stopHash)
->and($phrase->invoke($manager, $deleteHash))->toBe('cleanup-coolify-' . substr($deleteHash, 0, 12));
});
@@ -1,6 +1,6 @@
<?php
it('registers superuser replication endpoints and permissions', function (): void {
it('keeps the superuser replication read endpoint and retires mutation handlers', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
expect($content)->not->toBeFalse();
@@ -18,17 +18,32 @@ it('registers superuser replication endpoints and permissions', function (): voi
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')");
expect($content)->toContain('RETIRED_MANAGEMENT_MESSAGE');
expect($content)->toContain('private function rejectRetiredManagement(): void');
expect($content)->toContain("\$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);");
expect($content)->not->toContain('->addHost(');
expect($content)->not->toContain('->generateComposeTemplate(');
expect($content)->not->toContain('->testUnsavedCredentials(');
expect($content)->not->toContain('->testHost(');
expect($content)->not->toContain('->provisionHost(');
expect($content)->not->toContain('->promoteHost(');
expect($content)->not->toContain('->renameHost(');
expect($content)->not->toContain('->deleteHost(');
});
it('documents replication management in openapi', function (): void {
it('documents retired replication mutations in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('/superuser/replication:');
expect($content)->toContain('operationId: getSuperuserReplication');
expect($content)->toContain('summary: Retired database replication host creation');
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
expect($content)->toContain('operationId: renameSuperuserReplicationHost');
expect(preg_match_all('/deprecated:\s+true/', $content))->toBeGreaterThanOrEqual(9);
expect($content)->toContain("'410': { \$ref: '#/components/responses/Gone' }");
expect($content)->toContain('Gone:');
expect($content)->toContain('enum: [database, redis, minio]');
expect($content)->toContain('space_headroom_percent');
expect($content)->toContain('SuperuserReplicationStatus');
@@ -37,6 +52,17 @@ it('documents replication management in openapi', function (): void {
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
});
it('keeps superuser system status independent from replica health', function (): void {
$content = file_get_contents(app_path('classes/superuser_system_status_service.php'));
expect($content)->not->toBeFalse();
expect($content)->not->toContain('dependencyReplication');
expect($content)->not->toContain('replicationStatusFallback');
expect($content)->not->toContain("\$dependencies['database']['replication']");
expect($content)->not->toContain("\$dependencies['redis']['replication']");
expect($content)->not->toContain("\$dependencies['minio']['replication']");
});
it('rejects subuser sessions before checking replication permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
@@ -0,0 +1,75 @@
<?php
it('registers the superuser security endpoints and permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/system/security/summary');
expect($content)->toContain('/superuser/system/security/settings');
expect($content)->toContain('/superuser/system/security/firewall-rules');
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}');
expect($content)->toContain('/superuser/system/security/incidents');
expect($content)->toContain('/superuser/system/security/incidents/{id}');
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes');
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_view')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_settings_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_firewall_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_incidents_manage')");
expect($content)->toContain("'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents'");
});
it('keeps superuser security controls unavailable to subuser sessions', function (): void {
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool');
expect($content)->toContain('get_subuser() !== false');
expect($content)->toContain("Subuser sessions cannot manage security controls.");
expect($content)->toContain('return $this->requirePermission($permission);');
expect($content)->not->toContain("requirePermission('superuser_security_");
});
it('wires request and domain event observation into the backend', function (): void {
$routeTrait = file_get_contents(app_path('traits/route_t.php'));
$authRoute = file_get_contents(app_path('routes/authRoute.php'));
$bookingRoute = file_get_contents(app_path('routes/orderBookingRoute.php'));
$vehiclesRoute = file_get_contents(app_path('routes/vehiclesRoute.php'));
expect($routeTrait)->toContain('security_policy_service');
expect($routeTrait)->toContain('inspectRequest($route, $method)');
expect($authRoute)->toContain('observeLoginFailure(');
expect($bookingRoute)->toContain('observeBookingCreated');
expect($vehiclesRoute)->toContain('observeVehicleCreated');
});
it('defines persistent security tables for runtime and api tests', function (): void {
$runtimeSchema = file_get_contents(app_path('classes/security_schema_bootstrap.php'));
$testSchema = file_get_contents(app_path('tests/Support/Api/ApiSchemaBootstrap.php'));
foreach ([
'security_firewall_rules',
'security_policy_rules',
'security_policy_events',
'security_incidents',
'security_incident_notes',
] as $table) {
expect($runtimeSchema)->toContain($table);
expect($testSchema)->toContain($table);
}
});
it('documents superuser security endpoints in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/system/security/summary:');
expect($content)->toContain('/superuser/system/security/settings:');
expect($content)->toContain('/superuser/system/security/firewall-rules:');
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}:');
expect($content)->toContain('/superuser/system/security/incidents:');
expect($content)->toContain('/superuser/system/security/incidents/{id}:');
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes:');
expect($content)->toContain('SuperuserSecuritySettingsUpdateRequest');
expect($content)->toContain('SuperuserSecurityFirewallRuleMutation');
expect($content)->toContain('SuperuserSecurityIncidentResponse');
});
@@ -28,6 +28,14 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
public ?array $moduleConfigRowsOverride = null;
public bool $backupStoreValidationShouldFail = false;
public string $backupStoreFailureMessage = 'Backup bucket is missing.';
public array $backupHealthSummary = [
'latest_verified_backup' => ['backup_uuid' => 'verified-backup'],
'latest_verified_age_seconds' => 60,
'fresh' => true,
'encryption' => ['available' => true, 'key_id' => 'test-key'],
'verification_required' => true,
'restore_enabled' => false,
];
public bool $selfserveBootstrapShouldFail = false;
public string $selfserveBootstrapFailureMessage = 'Schema bootstrap failed.';
public bool $selfserveMinuteProductExistsValue = true;
@@ -167,6 +175,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
}
}
protected function backupHealthSummary(): array
{
return $this->backupHealthSummary;
}
protected function bootstrapSelfserveSchema(): void
{
if ($this->selfserveBootstrapShouldFail) {
@@ -525,6 +538,41 @@ it('reports backup probe failures from local validation', function (): void {
expect($result['status_reason_key'])->toBe('backup_probe_failed');
});
it('reports backups down when encryption key is missing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['encryption'] = [
'available' => false,
'error' => 'BACKUP_ENCRYPTION_KEY_V1 is not configured.',
];
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('down');
expect($result['status_reason_key'])->toBe('backup_encryption_key_missing');
});
it('reports backups degraded when no verified backup is available', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['latest_verified_backup'] = null;
$service->backupHealthSummary['fresh'] = false;
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('degraded');
expect($result['status_reason_key'])->toBe('backup_no_verified_backup');
});
it('reports backups degraded when the latest verified backup is stale', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['fresh'] = false;
$service->backupHealthSummary['latest_verified_age_seconds'] = 7200;
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('degraded');
expect($result['status_reason_key'])->toBe('backup_latest_verified_stale');
});
it('returns configured for shelly when no known device id is available for probing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();