Add system status displays for Minio and Redis, and enhance backup configuration
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user