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; +}