From 01221d8282631ecb8555a142eef0ca6d9f114a4b Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 09:24:33 +0200 Subject: [PATCH] Allow customer self-serve lane commands --- .../nginx/app/routes/moduleSelfServeRoute.php | 156 ++++++++++++-- .../tests/Api/SelfserveLaneCommandApiTest.php | 203 ++++++++++++++++++ .../app/tests/Support/Api/ApiFixtures.php | 49 +++++ 3 files changed, 390 insertions(+), 18 deletions(-) create mode 100644 services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index e90de548..54909880 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -458,23 +458,34 @@ class moduleSelfServeRoute self::requireMinValue($lane_id, 1); $commandParam = (string)$this->getParameter($param_command); self::requireType($commandParam, self::type_string()); - // Get the lane and command - $lane = $selfserve->lane($lane_id); - // Require access to the lane's department to prevent cross-department command execution - if (empty($lane->department_lane) || empty($lane->department_lane->department)) { - $response->error('Lane department not found', 404); - } - self::requireDepartmentAccess((string)$lane->department_lane->department->value()); - // If the user has the bypass permission, set the lane to bypass customer number validation - if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) { - $lane->setBypassCustomerNumberValidation(true); - } $command = selfserve_lane_command::tryFrom($commandParam); if ($command === null) { $response->error("Invalid command: " . $commandParam); } + // Get the lane and command + $lane = $selfserve->lane($lane_id); + // Preserve not-found behavior before evaluating elevated/customer alternatives. + if (empty($lane->department_lane) || empty($lane->department_lane->department)) { + $response->error('Lane department not found', 404); + } $customer_number = $this->resolveEffectiveCustomerNumber(); $customer_number = $customer_number === null ? 0 : (int)$customer_number; + [ + $allow_customer_self_serve, + $requires_active_wash, + $allow_department_active_wash + ] = $this->customerSelfServeCommandAccessRequirements($command); + $this->requireSelfServeLaneDepartmentOrCustomerAccess( + $lane, + $customer_number, + $allow_customer_self_serve, + $requires_active_wash, + $allow_department_active_wash + ); + // If the user has the bypass permission, set the lane to bypass customer number validation + if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) { + $lane->setBypassCustomerNumberValidation(true); + } // Require permissions for specific commands switch ($command) { case selfserve_lane_command::START: @@ -482,7 +493,7 @@ class moduleSelfServeRoute $lane, $customer_number, 'modules_selfserve_lane_command_execute_start', - false + true ); break; case selfserve_lane_command::STOP: @@ -491,7 +502,8 @@ class moduleSelfServeRoute $customer_number, 'modules_selfserve_lane_command_execute_stop', true, - true + true, + false ); break; case selfserve_lane_command::RESERVE: @@ -519,10 +531,18 @@ class moduleSelfServeRoute ); break; case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE: - self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate'); + $this->requirePropertyGateCommandPermission( + 'modules_selfserve_lane_command_execute_open_property_access_gate', + $lane, + $customer_number + ); break; case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: - self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate'); + $this->requirePropertyGateCommandPermission( + 'modules_selfserve_lane_command_execute_open_property_exit_gate', + $lane, + $customer_number + ); break; } // Execute the command @@ -1361,6 +1381,51 @@ class moduleSelfServeRoute $lane->setShellyTransportOverride($transport); } + /** + * @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed] + */ + private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array + { + return match ($command) { + selfserve_lane_command::START => [true, false, false], + selfserve_lane_command::STOP => [true, true, false], + selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, + selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true], + selfserve_lane_command::RESERVE, + selfserve_lane_command::RELEASE, + selfserve_lane_command::RESET => [false, false, false], + }; + } + + private function requireSelfServeLaneDepartmentOrCustomerAccess( + selfserve_lane $lane, + int $customer_number, + bool $allow_customer_self_serve, + bool $requires_active_wash = false, + bool $allow_department_active_wash = false + ): void { + $department_id = $this->departmentIdForLane($lane); + if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) { + return; + } + + if ($allow_customer_self_serve) { + $customer_allowed = $requires_active_wash + ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash) + : $this->canCustomerUseSelfServeLane($lane, $customer_number); + + if ($customer_allowed) { + return; + } + } + + $missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : []; + if ($allow_customer_self_serve) { + $missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION; + } + $this->emitForbidden($missing_permissions); + } + /** * @param array $permissions */ @@ -1408,7 +1473,8 @@ class moduleSelfServeRoute int $customer_number, string $command_permission, bool $allow_customer_self_serve, - bool $requires_active_wash = false + bool $requires_active_wash = false, + bool $allow_department_active_wash = false ): void { $elevated_permissions = [ 'modules_selfserve_lane_command_execute', @@ -1420,7 +1486,7 @@ class moduleSelfServeRoute if ($allow_customer_self_serve) { $customer_allowed = $requires_active_wash - ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + ? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash) : $this->canCustomerUseSelfServeLane($lane, $customer_number); if ($customer_allowed) { @@ -1480,7 +1546,61 @@ class moduleSelfServeRoute protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool { - return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); + return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true); + } + + protected function canCustomerUseActiveOperationalSelfServeLane( + selfserve_lane $lane, + int $customer_number, + bool $allow_department_active_wash = false + ): bool { + return $this->isLaneSelfServeOperationallyEnabled($lane) + && ( + $allow_department_active_wash + ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) + : $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number) + ); + } + + protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool + { + if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) { + return false; + } + + try { + if ((int)$lane->getCustomerNumber() === $customer_number) { + return true; + } + } catch (\Throwable) { + // Fall back to the persisted lane session lookup below. + } + + $active_statuses = array_map( + static fn(selfserve_wash_session_status $status): string => $status->value, + [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED, + selfserve_wash_session_status::READY_FOR_MACHINE_START, + selfserve_wash_session_status::MACHINE_STARTED, + selfserve_wash_session_status::PENDING_QUESTIONS, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED, + ] + ); + + $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'lane_id' => (int)$lane->id, + 'customer_number' => $customer_number, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id', 'status']); + + foreach ($sessions as $session) { + if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { + return true; + } + } + + return false; } protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool diff --git a/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php new file mode 100644 index 00000000..bb0718ec --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveLaneCommandApiTest.php @@ -0,0 +1,203 @@ + getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis', + 'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default', + 'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0', + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '', + 'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379', + ]; + + define('redis', (new \classes\redis())->connect()); +} + +function selfserve_lane_command_lane(int $laneId): \modules\selfserve\classes\selfserve_lane +{ + selfserve_lane_command_ensure_legacy_redis_constant(); + + return (new \classes\selfserve())->lane($laneId); +} + +function selfserve_lane_command_make_available(int $laneId): void +{ + $lane = selfserve_lane_command_lane($laneId); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IDLE); + $lane->setCustomerNumber(0); + $lane->setLicensePlate(''); + $lane->setWashStartTime(0); +} + +function selfserve_lane_command_make_occupied(int $laneId, int $customerNumber, string $licensePlate): void +{ + $lane = selfserve_lane_command_lane($laneId); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + $lane->setCustomerNumber($customerNumber); + $lane->setLicensePlate($licensePlate); + $lane->setWashStartTime(time() - 60); +} + +it('allows customer self-serve permission to execute START without department access when department and lane are enabled', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['status'] ?? null)->toBe('OCCUPIED') + ->and($response->data()['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']); +}); + +it('denies customer START when department self-serve is disabled', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => false, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('denies customer START when lane self-serve is disabled', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => false, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => (string)$scenario['vehicle']['reg'], + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('denies a customer STOP for another customers active lane', function (): void { + $scenario = api_fixtures()->createSelfServeScenario([ + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_occupied( + (int)$scenario['lane']['id'], + (int)$scenario['customer']['customer_number'], + (string)$scenario['vehicle']['reg'] + ); + $otherSession = api_fixtures()->createUserSession([ + 'list_own_department_selfserve_vehicle_conditions', + ]); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'STOP', + ], $otherSession['headers']); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('still allows elevated operators with department access to execute lane commands', function (): void { + $scenario = api_fixtures()->createSelfServeScenario([ + 'department_selfserve_enabled' => false, + 'lane_selfserve_enabled' => false, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $session = api_fixtures()->createUserSession([ + 'department_access_' . (int)$scenario['department']['id'], + 'modules_selfserve_lane_command_execute', + 'modules_selfserve_lane_command_execute_start', + ]); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'START', + 'license_plate' => 'OP' . (int)$scenario['lane']['id'], + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['status'] ?? null)->toBe('OCCUPIED'); +}); + +it('keeps operator-only commands elevated-only for customers and reports command permissions', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => ['group_id' => $group['id']], + 'department_selfserve_enabled' => true, + 'lane_selfserve_enabled' => true, + ]); + selfserve_lane_command_make_available((int)$scenario['lane']['id']); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->post('/modules/self-serve/lane/command', [ + 'lane_id' => (int)$scenario['lane']['id'], + 'command' => 'RESET', + ], api_fixtures()->bearerHeaders($token)); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'department_access_' . (int)$scenario['department']['id'], + ]); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 0e25bfbb..b4649240 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -145,6 +145,47 @@ final class ApiFixtures return ['id' => $departmentId]; } + public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void + { + if ($departmentId <= 0) { + throw new RuntimeException('Department self-serve fixtures require a positive department id.'); + } + + $this->cleanupDeleteWhere('department_variables', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + ]); + + $existingIds = $this->fetchIntColumnWhere('department_variables', 'id', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + ]); + if ($existingIds !== []) { + $this->updateById('department_variables', (int)$existingIds[0], [ + 'value' => $enabled ? 'true' : 'false', + ]); + return; + } + + $variableId = $this->insertRowWithExistingColumns('department_variables', [ + 'department_id' => $departmentId, + 'variable' => 'selfserve_enabled', + 'value' => $enabled ? 'true' : 'false', + ]); + $this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId)); + } + + public function setLaneSelfServeEnabled(int $laneId, bool $enabled): void + { + if ($laneId <= 0) { + throw new RuntimeException('Lane self-serve fixtures require a positive lane id.'); + } + + $this->updateById('department_lanes', $laneId, [ + 'selfserve_enabled' => $enabled ? 1 : 0, + ]); + } + /** * @param array $attributes * @return array @@ -295,6 +336,14 @@ final class ApiFixtures $laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData); $this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId)); + $this->setDepartmentSelfServeEnabled( + (int)$department['id'], + (bool)($overrides['department_selfserve_enabled'] ?? true) + ); + if (array_key_exists('lane_selfserve_enabled', $overrides)) { + $this->setLaneSelfServeEnabled($laneId, (bool)$overrides['lane_selfserve_enabled']); + } + $customer = $this->createUser(array_merge([ 'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix), 'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix),