From eefa521fc52ba7968ffb27cc89fde5ae4e8fbcf9 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 09:14:57 +0200 Subject: [PATCH 1/2] Guard customer self-serve commands behind module flag --- .../nginx/app/routes/moduleSelfServeRoute.php | 11 ++- .../SelfserveCustomerLaneAccessTest.php | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index e90de548..562f7ef5 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -1418,7 +1418,7 @@ class moduleSelfServeRoute return; } - if ($allow_customer_self_serve) { + if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) { $customer_allowed = $requires_active_wash ? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number) : $this->canCustomerUseSelfServeLane($lane, $customer_number); @@ -1452,6 +1452,15 @@ class moduleSelfServeRoute $this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]); } + protected function isSelfServeModuleEnabled(): bool + { + try { + return (bool)(new selfserve())->config->enabled->getVariableValue(); + } catch (\Throwable) { + return false; + } + } + protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool { return $customer_number > 0 diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php index 3c460973..29f44aec 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php @@ -17,9 +17,12 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute 'list_own_department_selfserve_vehicle_conditions' => true, ]; public bool $laneEnabled = true; + public bool $moduleEnabled = true; public bool $activeWashResult = false; /** @var array */ public array $activeWashChecks = []; + /** @var array */ + public array $forbiddenPermissions = []; public function __construct() { @@ -42,11 +45,33 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); } + protected function isSelfServeModuleEnabled(): bool + { + return $this->moduleEnabled; + } + protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool { return $this->laneEnabled; } + protected function emitForbidden(array $permissions): void + { + $this->forbiddenPermissions = array_values($permissions); + throw new RuntimeException('forbidden'); + } + + public function requireCommandPermissionForTest( + selfserve_lane $lane, + int $customer_number, + string $command_permission, + bool $allow_customer_self_serve, + bool $requires_active_wash = false + ): void { + $method = new ReflectionMethod(moduleSelfServeRoute::class, 'requireSelfServeLaneCommandPermission'); + $method->invoke($this, $lane, $customer_number, $command_permission, $allow_customer_self_serve, $requires_active_wash); + } + protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool { $this->activeWashChecks[] = [ @@ -157,3 +182,62 @@ it('blocks active wash operations for other customers', function (): void { ], ]); }); + + +it('denies customer self-serve command fallback when the self-serve module is disabled', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->moduleEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + expect(fn() => $route->requireCommandPermissionForTest( + $lane, + 12345679, + 'modules_selfserve_lane_command_execute_stop', + true, + true + ))->toThrow(RuntimeException::class, 'forbidden'); +}); + +it('returns deterministic forbidden permissions when disabled module blocks customer self-serve command fallback', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->moduleEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + try { + $route->requireCommandPermissionForTest( + $lane, + 12345679, + 'modules_selfserve_lane_command_execute_stop', + true, + true + ); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toBe('forbidden'); + } + + expect($route->forbiddenPermissions)->toBe([ + 'modules_selfserve_lane_command_execute', + 'modules_selfserve_lane_command_execute_stop', + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('continues allowing operator self-serve commands when the self-serve module is disabled', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->moduleEnabled = false; + $route->permissions = [ + 'modules_selfserve_lane_command_execute' => true, + 'modules_selfserve_lane_command_execute_stop' => true, + ]; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + $route->requireCommandPermissionForTest( + $lane, + 12345679, + 'modules_selfserve_lane_command_execute_stop', + true, + true + ); + + expect($route->forbiddenPermissions)->toBe([]); +}); From 46bdeded7820b21bf225407a7775112e301242a3 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 09:15:24 +0200 Subject: [PATCH 2/2] Fix self-serve lane command customer authorization --- .../nginx/app/routes/moduleSelfServeRoute.php | 49 ++++++++----- .../Selfserve/SelfserveRouteWiringTest.php | 73 +++++++++++++++++++ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index e90de548..fc191a02 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -460,11 +460,6 @@ class moduleSelfServeRoute 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); @@ -482,7 +477,7 @@ class moduleSelfServeRoute $lane, $customer_number, 'modules_selfserve_lane_command_execute_start', - false + true ); break; case selfserve_lane_command::STOP: @@ -495,34 +490,36 @@ class moduleSelfServeRoute ); break; case selfserve_lane_command::RESERVE: - $this->requireSelfServeLaneCommandPermission( + $this->requireOperatorLaneCommandPermission( $lane, - $customer_number, - 'modules_selfserve_lane_command_execute_reserve', - false + 'modules_selfserve_lane_command_execute_reserve' ); break; case selfserve_lane_command::RELEASE: - $this->requireSelfServeLaneCommandPermission( + $this->requireOperatorLaneCommandPermission( $lane, - $customer_number, - 'modules_selfserve_lane_command_execute_release', - false + 'modules_selfserve_lane_command_execute_release' ); break; case selfserve_lane_command::RESET: - $this->requireSelfServeLaneCommandPermission( + $this->requireOperatorLaneCommandPermission( $lane, - $customer_number, - 'modules_selfserve_lane_command_execute_reset', - false + 'modules_selfserve_lane_command_execute_reset' ); 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 @@ -1435,6 +1432,18 @@ class moduleSelfServeRoute ); } + private function requireOperatorLaneCommandPermission(selfserve_lane $lane, string $command_permission): void + { + if (empty($lane->department_lane) || empty($lane->department_lane->department)) { + global $response; + $response->error('Lane department not found', 404); + } + + self::requireDepartmentAccess((string)$lane->department_lane->department->value()); + self::requirePermission('modules_selfserve_lane_command_execute'); + self::requirePermission($command_permission); + } + private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void { $elevated_permissions = [ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 2f6953c7..b742cbea 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -465,3 +465,76 @@ it('keeps read-only self-serve preview and summary refreshes from touching relay expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); }); + +it('classifies self-serve lane command route authorization by customer product behavior', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + $productDocs = file_get_contents(app_path('modules/selfserve/selfserve.md')); + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($productDocs)->not->toBeFalse(); + expect($washFlow)->not->toBeFalse(); + + // Product flow confirms the customer UI sends STOP, while route docs expose START and property gates + // as customer-scoped lane commands. RESERVE, RELEASE, and RESET remain absent from customer flow/docs. + expect($productDocs)->toContain('Customer->>API: POST /modules/self-serve/lane/command (STOP)'); + expect($productDocs)->toContain('customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands'); + expect($productDocs)->toContain('Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer\'s active wash in the lane department.'); + expect($washFlow)->toContain('$payload[\'command\'] = $relayRole === \'PROPERTY_ENTRANCE\' ? \'OPEN_PROPERTY_ACCESS_GATE\' : \'OPEN_PROPERTY_EXIT_GATE\''); + expect($washFlow)->toContain('$signalType = \'studio_action_gate_open\''); + + $commandCases = selfserve_lane_command_route_cases($moduleSelfServeRoute); + + expect($commandCases['START']) + ->toContain('requireSelfServeLaneCommandPermission') + ->toContain("'modules_selfserve_lane_command_execute_start'") + ->toContain("true\n );") + ->not->toContain('requireDepartmentAccess') + ->not->toContain('requireOperatorLaneCommandPermission'); + + expect($commandCases['STOP']) + ->toContain('requireSelfServeLaneCommandPermission') + ->toContain("'modules_selfserve_lane_command_execute_stop'") + ->toContain("true,\n true") + ->not->toContain('requireDepartmentAccess') + ->not->toContain('requireOperatorLaneCommandPermission'); + + expect($commandCases['OPEN_PROPERTY_ACCESS_GATE']) + ->toContain('requirePropertyGateCommandPermission') + ->toContain("'modules_selfserve_lane_command_execute_open_property_access_gate'") + ->not->toContain('self::requirePermission') + ->not->toContain('requireDepartmentAccess'); + + expect($commandCases['OPEN_PROPERTY_EXIT_GATE']) + ->toContain('requirePropertyGateCommandPermission') + ->toContain("'modules_selfserve_lane_command_execute_open_property_exit_gate'") + ->not->toContain('self::requirePermission') + ->not->toContain('requireDepartmentAccess'); + + foreach (['RESERVE', 'RELEASE', 'RESET'] as $operatorOnlyCommand) { + expect($commandCases[$operatorOnlyCommand]) + ->toContain('requireOperatorLaneCommandPermission') + ->not->toContain('requireSelfServeLaneCommandPermission') + ->not->toContain('requirePropertyGateCommandPermission'); + } +}); + +/** + * @return array + */ +function selfserve_lane_command_route_cases(string $moduleSelfServeRoute): array +{ + $cases = []; + preg_match_all( + '/case selfserve_lane_command::([A-Z_]+):(.*?)(?=\n\s*case selfserve_lane_command::|\n\s*}\n\s*\/\/ Execute the command)/s', + $moduleSelfServeRoute, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $match) { + $cases[$match[1]] = $match[2]; + } + + return $cases; +}