diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 27d10dea..0e7a2ece 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -46,8 +46,6 @@ jobs: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_ENDPOINT: 'https://qodana.cloud' - - name: 'Qodana Scan (without cloud upload)' + - name: 'Skip Qodana Scan (missing cloud token)' if: ${{ steps.qodana-token.outputs.present != 'true' }} - uses: JetBrains/qodana-action@v2026.1 - with: - pr-mode: false + run: echo "Skipping Qodana because QODANA_TOKEN is not configured." diff --git a/services/caddy/Caddyfile b/services/caddy/Caddyfile index 984e91d3..4f529477 100644 --- a/services/caddy/Caddyfile +++ b/services/caddy/Caddyfile @@ -10,9 +10,15 @@ # CORS is handled at the edge by Traefik's headers middleware. # Do not set or strip Access-Control-* headers here to avoid conflicts. +<<<<<<< HEAD + # Replication bootstrap snapshots contain sensitive failover credentials. + @replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-* + respond @replication_bootstrap 404 +======= # Do not expose local replication bootstrap material from the public web root. @replicationBootstrap path /storage/replication-bootstrap.json respond @replicationBootstrap 404 +>>>>>>> refs/remotes/origin/master # PHP handling via FastCGI to php-fpm pool php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000 diff --git a/services/caddy/Caddyfile-staging b/services/caddy/Caddyfile-staging index 0a82c726..81da3375 100644 --- a/services/caddy/Caddyfile-staging +++ b/services/caddy/Caddyfile-staging @@ -10,9 +10,15 @@ # CORS is handled at the edge by Traefik's headers middleware. # Do not set or strip Access-Control-* headers here to avoid conflicts. +<<<<<<< HEAD + # Replication bootstrap snapshots contain sensitive failover credentials. + @replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-* + respond @replication_bootstrap 404 +======= # Do not expose local replication bootstrap material from the public web root. @replicationBootstrap path /storage/replication-bootstrap.json respond @replicationBootstrap 404 +>>>>>>> refs/remotes/origin/master # PHP handling via FastCGI to php-fpm pool php_fastcgi php-staging:9000 diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index ebcb31ad..bb8e0a56 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -533,10 +533,7 @@ class invoice_period_flag_service if (!is_array($flags)) { $flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo); - try { - (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); - } catch (Throwable) { - } + $this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags); } if ($onlyCustomerNumbers === null) { @@ -552,12 +549,11 @@ class invoice_period_flag_service public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void { [$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo); - $flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo); - - try { - (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); - } catch (Throwable) { - } + $this->cacheAutomaticFlagsForPeriod( + $dateFrom, + $dateTo, + $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo) + ); } private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array @@ -575,6 +571,14 @@ class invoice_period_flag_service ); } + private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void + { + try { + (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); + } catch (Throwable) { + } + } + private function filterSuppressedAutomaticFlags(array $flags): array { global $db; @@ -1760,18 +1764,7 @@ class invoice_period_flag_service if ($currentVehicleType === '' || $expectedVehicleType === '') { return false; } - if ($currentVehicleType === $expectedVehicleType) { - return true; - } - // Allow a match if one normalized name's tokens are a subset of the other. - // E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of - // "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type. - $currentTokens = explode(' ', $currentVehicleType); - $expectedTokens = explode(' ', $expectedVehicleType); - if (count($currentTokens) <= count($expectedTokens)) { - return array_diff($currentTokens, $expectedTokens) === []; - } - return array_diff($expectedTokens, $currentTokens) === []; + return $currentVehicleType === $expectedVehicleType; } private function normalizePrimaryVehicleProductName(string $productName): string diff --git a/services/nginx/app/classes/order_reference_suggestions_service.php b/services/nginx/app/classes/order_reference_suggestions_service.php index c0daf1a9..a20bbda5 100644 --- a/services/nginx/app/classes/order_reference_suggestions_service.php +++ b/services/nginx/app/classes/order_reference_suggestions_service.php @@ -46,7 +46,7 @@ class order_reference_suggestions_service $rows = [ ...$this->fetchBookingRows($departmentId, $search), ...$this->fetchOrderRows($departmentId, $search), - ...$this->fetchVehicleRows($customerId, $plates, $search), + ...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search), ]; $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); @@ -137,7 +137,7 @@ class order_reference_suggestions_service * @param array $plates * @return array> */ - private function fetchVehicleRows(?int $customerId, array $plates, string $search): array + private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array { $contextWhere = []; $params = []; @@ -171,6 +171,10 @@ class order_reference_suggestions_service $params['search'] = '%' . $this->lower($search) . '%'; } + $where[] = $this->vehicleDepartmentAccessPredicate(); + $params['orders_department_id'] = $departmentId; + $params['bookings_department_id'] = $departmentId; + $sql = "SELECT 'vehicle' AS source, id AS origin_id, @@ -190,6 +194,41 @@ class order_reference_suggestions_service return $this->fetchRows($sql, $params); } + private function vehicleDepartmentAccessPredicate(): string + { + $ordersWhere = [ + 'authorized_orders.department_id = :orders_department_id', + '(authorized_orders.customer_id = customer_vehicles.customer_id' + . " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" + ]; + if ($this->tableHasColumn('orders', 'deleted_at')) { + $ordersWhere[] = 'authorized_orders.deleted_at IS NULL'; + } + + $bookingsWhere = [ + 'authorized_bookings.department = :bookings_department_id', + '(authorized_bookings.customer_number = customer_vehicles.customer_id' + . " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" + ]; + if ($this->tableHasColumn('order_bookings', 'deleted_at')) { + $bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL'; + } + + return '(EXISTS ( + SELECT 1 + FROM orders authorized_orders + WHERE ' . implode(' AND ', $ordersWhere) . ' + ) OR EXISTS ( + SELECT 1 + FROM order_bookings authorized_bookings + WHERE ' . implode(' AND ', $bookingsWhere) . ' + ))'; + } + /** * @param array $params * @return array> diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php index d05fff0d..d0f42778 100644 --- a/services/nginx/app/classes/orders_schema_bootstrap.php +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -58,6 +58,9 @@ class orders_schema_bootstrap || !self::columnExists($db, 'orders', 'booking_id') || !self::columnExists($db, 'orders', 'po') || !self::columnExists($db, 'order_bookings', 'po') + || !self::columnExists($db, 'order_bookings', 'customer_number') + || !self::columnExists($db, 'order_bookings', 'department') + || !self::columnExists($db, 'order_bookings', 'deleted_at') ) { return; } @@ -65,6 +68,9 @@ class orders_schema_bootstrap $db->query( "UPDATE orders o INNER JOIN order_bookings b ON b.id = o.booking_id + AND b.customer_number = o.customer_id + AND b.department = o.department_id + AND b.deleted_at IS NULL SET o.po = b.po WHERE o.booking_id IS NOT NULL AND o.booking_id > 0 diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 64947855..ef51d88c 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -1021,6 +1021,7 @@ class release_manager 'channel_slug' => $channelSlug, 'route_slug' => $routeSlug, 'app' => $app, + 'apps' => $this->releaseTestAppsFromInput($input), 'repository' => $repository, 'branch' => $branch, 'auto_sync' => $this->toBool($input['auto_sync'] ?? false), @@ -4802,22 +4803,22 @@ class release_manager throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.'); } $this->assertBetaProductionDataPolicy($channel, $serviceSet); + $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); + $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); $this->assertReleaseGatePassedForPromotion( $channelId, (string)($bundle['frontend_commit_sha'] ?? ''), null, 'frontend' ); - if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') { + if ($apiVersionId !== null) { $this->assertReleaseGatePassedForPromotion( $channelId, - (string)$bundle['api_commit_sha'], + (string)($bundle['api_commit_sha'] ?? ''), null, 'api' ); } - $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); - $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); @@ -9432,7 +9433,7 @@ class release_manager 'status' => 'draft', 'metadata' => [ 'commit_mode' => $input['commit_mode'], - 'github_access' => $input['github_access'], + 'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null), 'bundle_member' => true, ], ]); @@ -9765,7 +9766,7 @@ class release_manager return null; } - $metadata = self::jsonDecode($version['metadata_json'] ?? null); + $metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null)); $commit = $this->versionGithubCommit(['metadata' => $metadata]); return [ @@ -9788,6 +9789,26 @@ class release_manager ]; } + private static function publicReleaseVersionMetadata(mixed $metadata): array + { + if (!is_array($metadata)) { + return []; + } + + unset($metadata['github_access']); + return $metadata; + } + + private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array + { + if (!is_array($githubAccess)) { + return null; + } + + unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']); + return $githubAccess; + } + private function publicAssignment(array $assignment): array { return [ diff --git a/services/nginx/app/classes/system_search_service.php b/services/nginx/app/classes/system_search_service.php index e836f3c9..b0c5e4a6 100644 --- a/services/nginx/app/classes/system_search_service.php +++ b/services/nginx/app/classes/system_search_service.php @@ -207,25 +207,28 @@ class system_search_service $activeTypes, $this->associationEntityTypes() )); - foreach ($customerNumbers as $customerNumber) { - $associated = $this->executeLexicalSearch( - $associationTypes, - [(string)$customerNumber], - [], - $ownOnlyTypes, - $ownCustomerNumber, - $permissionsCatalogAll, - $permissionsCatalogOwn, - $moduleConfigVisibility, - [$customerNumber] - ); - foreach ($associated as &$item) { - if (!isset($item['association_reason'])) { - $item['association_reason'] = 'customer:' . $customerNumber; + $associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes)); + if (!empty($associationTypes)) { + foreach ($customerNumbers as $customerNumber) { + $associated = $this->executeLexicalSearch( + $associationTypes, + [(string)$customerNumber], + [], + $ownOnlyTypes, + $ownCustomerNumber, + $permissionsCatalogAll, + $permissionsCatalogOwn, + $moduleConfigVisibility, + [$customerNumber] + ); + foreach ($associated as &$item) { + if (!isset($item['association_reason'])) { + $item['association_reason'] = 'customer:' . $customerNumber; + } + $item['score'] = max((int)$item['score'], 35); } - $item['score'] = max((int)$item['score'], 35); + $initialResults = $this->mergeResults($initialResults, $associated); } - $initialResults = $this->mergeResults($initialResults, $associated); } } } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php index 3621d958..474f605f 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -46,6 +46,11 @@ use objects\selfserve_config_versions_o; class selfserve_studio_graph { + private const DEFAULT_PATH_MAX_STATES = 2048; + private const MAX_PATH_MAX_STATES = 2048; + private const DEFAULT_PATH_SAMPLE_LIMIT = 200; + private const MAX_PATH_SAMPLE_LIMIT = 200; + /** @var array> */ private array $columnCache = []; @@ -678,7 +683,11 @@ class selfserve_studio_graph $vehicleTypeIds[] = null; } - $maxStates = $this->pathLimit($payload['max_states'] ?? null); + $maxStates = $this->pathLimit( + $payload['max_states'] ?? null, + self::DEFAULT_PATH_MAX_STATES, + self::MAX_PATH_MAX_STATES + ); $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); if ($reg === '') { $reg = 'TEST123'; @@ -694,14 +703,18 @@ class selfserve_studio_graph $stateCount = 0; $terminalPathCount = 0; $questionIds = []; - $pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null); + $pathSampleLimit = $this->pathLimit( + $payload['path_sample_limit'] ?? null, + self::DEFAULT_PATH_SAMPLE_LIMIT, + self::MAX_PATH_SAMPLE_LIMIT + ); $paths = []; $scenarioCount = max(1, count($vehicleTypeIds)); $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource); foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) { - $remainingStates = $maxStates === null ? null : $maxStates - $stateCount; - if ($remainingStates !== null && $remainingStates <= 0) { + $remainingStates = $maxStates - $stateCount; + if ($remainingStates <= 0) { $truncated = true; break; } @@ -759,7 +772,7 @@ class selfserve_studio_graph $projectionOptions = [ 'scope' => $scenarioScope, 'max_states' => $remainingStates, - 'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)), + 'path_sample_limit' => max(0, $pathSampleLimit - count($paths)), 'progress_callback' => function (array $projection) use ( $progressCallback, &$outcomes, @@ -787,7 +800,7 @@ class selfserve_studio_graph $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? []))); $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? []))); - if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) { + if (count($partialPaths) > $pathSampleLimit) { $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit); } @@ -839,12 +852,6 @@ class selfserve_studio_graph }, 'confirmation_rows' => $confirmationRows, ]; - if ($remainingStates === null) { - unset($projectionOptions['max_states']); - } - if ($pathSampleLimit === null) { - unset($projectionOptions['path_sample_limit']); - } $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions); foreach ((array)($projection['outcomes'] ?? []) as $outcome) { if (is_array($outcome)) { @@ -855,7 +862,7 @@ class selfserve_studio_graph if (!is_array($path)) { continue; } - if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + if (count($paths) < $pathSampleLimit) { $paths[] = $path; } } @@ -918,9 +925,18 @@ class selfserve_studio_graph */ public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array { - $maxStates = $this->pathLimit($options['max_states'] ?? null); + $maxStates = $this->pathLimit( + $options['max_states'] ?? null, + self::DEFAULT_PATH_MAX_STATES, + self::MAX_PATH_MAX_STATES + ); $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); - $pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null); + $pathSampleLimit = $this->pathLimit( + $options['path_sample_limit'] ?? null, + self::DEFAULT_PATH_SAMPLE_LIMIT, + self::MAX_PATH_SAMPLE_LIMIT, + 0 + ); $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null; $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128)); $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; @@ -938,7 +954,7 @@ class selfserve_studio_graph $truncated = false; while ($stack !== []) { - if ($maxStates !== null && $stateCount >= $maxStates) { + if ($stateCount >= $maxStates) { $truncated = true; break; } @@ -997,7 +1013,7 @@ class selfserve_studio_graph $terminalPathCount++; $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); - if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + if (count($paths) < $pathSampleLimit) { $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); } @@ -4273,14 +4289,18 @@ class selfserve_studio_graph return null; } - private function pathLimit(mixed $value): ?int + private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int { if ($value === null || $value === '') { - return null; + return max($min, min($max, $default)); } $parsed = (int)$value; - return $parsed > 0 ? $parsed : null; + if ($parsed < $min) { + return max($min, min($max, $default)); + } + + return min($max, $parsed); } /** diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index 2efe709c..825f16ba 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -38,6 +38,49 @@ use objects\department_variables_o; trait selfserve_lane_command_t { + + /** + * Acquire an atomic per-lane START lock before performing physical side effects. + */ + protected function acquireLaneStartCommandLock(): string + { + if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) { + throw new \RuntimeException('Cannot start lane: START lock is unavailable.'); + } + + $token = bin2hex(random_bytes(16)); + $lock_key = $this->getLaneStartCommandLockKey(); + if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) { + throw new \RuntimeException("Cannot start lane: Lane is not available."); + } + + return $token; + } + + protected function releaseLaneStartCommandLock(string $token): void + { + if (!defined('redis')) { + return; + } + + $lock_key = $this->getLaneStartCommandLockKey(); + try { + if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) { + return; + } + if (method_exists(redis, 'delete')) { + redis->delete($lock_key); + } + } catch (\Throwable) { + // The lock has a short TTL, so release failures must not mask START results. + } + } + + protected function getLaneStartCommandLockKey(): string + { + return 'selfserve_lane_start_command_lock_' . (int)$this->id; + } + /** * Determine if the lane and its department have self-serve enabled. * This method is intentionally protected to allow tests to override @@ -509,38 +552,48 @@ trait selfserve_lane_command_t // Validate customer number if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number); if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number); - $previous_customer_number = $this->getCustomerNumber(); - $previous_license_plate = $this->getLicensePlate(); - // Set the customer number and license plate - $this->setCustomerNumber($customer_number); - $this->setLicensePlate($license_plate); + $start_lock_token = $this->acquireLaneStartCommandLock(); try { - // Open the entrance port before marking the lane occupied. Gateway timeouts are - // ambiguous because the relay may already have received the pulse. - $this->openEntrancePortForWashStart(); - } catch (\Throwable $e) { - $this->setCustomerNumber($previous_customer_number); - $this->setLicensePlate($previous_license_plate); - $this->setLaneState(selfserve_lane_state::IDLE); - throw $e; + // Re-check availability after taking the START lock so concurrent requests cannot + // both pass the preflight check and trigger the physical entrance relay. + if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available."); + $previous_customer_number = $this->getCustomerNumber(); + $previous_license_plate = $this->getLicensePlate(); + $previous_status = $this->getLaneStatus(); + // Set the customer number and license plate + $this->setCustomerNumber($customer_number); + $this->setLicensePlate($license_plate); + // Mark the lane occupied before any physical entrance relay side effects. + $this->setLaneStatus(selfserve_lane_status::OCCUPIED); + try { + // Gateway timeouts are ambiguous because the relay may already have received + // the pulse, so openEntrancePortForWashStart() reports them and continues. + $this->openEntrancePortForWashStart(); + } catch (\Throwable $e) { + $this->setCustomerNumber($previous_customer_number); + $this->setLicensePlate($previous_license_plate); + $this->setLaneStatus($previous_status); + $this->setLaneState(selfserve_lane_state::IDLE); + throw $e; + } + // Set the lane state to IN_WASH + $this->setLaneState(selfserve_lane_state::IN_WASH); + // Start the wash timer + $this->setWashStartTime(time()); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $this->resolveSelfServeActionWashModeForStart(), + [ + 'customer_number' => (int)$customer_number, + 'reg' => $license_plate, + ] + ); + $this->runRelaySideEffectsForWashStart($arguments); + // Log the lane start event + $this->logLaneAction(selfserve_lane_log_action::START_WASH); + } finally { + $this->releaseLaneStartCommandLock($start_lock_token); } - // Set the lane status to OCCUPIED when started - $this->setLaneStatus(selfserve_lane_status::OCCUPIED); - // Set the lane state to IN_WASH - $this->setLaneState(selfserve_lane_state::IN_WASH); - // Start the wash timer - $this->setWashStartTime(time()); - $this->runPublishedStudioActions( - selfserve_studio_actions::EVENT_WASH_START_COMMAND, - $this->resolveSelfServeActionWashModeForStart(), - [ - 'customer_number' => (int)$customer_number, - 'reg' => $license_plate, - ] - ); - $this->runRelaySideEffectsForWashStart($arguments); - // Log the lane start event - $this->logLaneAction(selfserve_lane_log_action::START_WASH); break; case selfserve_lane_command::STOP: // Require lane to be occupied before stopping diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 1fa7d0b2..cc892f50 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -18283,13 +18283,17 @@ components: max_states: type: integer minimum: 1 + maximum: 2048 + default: 2048 nullable: true - description: Optional debug cap. Omit for complete path projection. + description: Optional debug cap for explored states. Omitted and larger values are capped at 2048. path_sample_limit: type: integer minimum: 1 + maximum: 200 + default: 200 nullable: true - description: Optional debug cap for returned path rows. Omit to return every terminal path row. + description: Optional cap for returned path rows. Omitted and larger values are capped at 200. SelfserveStudioPathOutcomesResponse: type: object diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index e9fa78fd..018e861b 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -835,8 +835,9 @@ class InvoicingPeriodRoute $response->add_meta('customer_numbers', $customerNumbers); } $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); + $includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags'); // Get the invoicing period for the user - $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers); + $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags); if ($paginationOptions !== null) { $paginated = self::applyPeriodPagination($period, $paginationOptions); $period = $paginated['period']; @@ -1841,7 +1842,12 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array + private static function getInvoicingPeriod( + string $dateFrom, + string $dateTo, + ?array $onlyCustomerNumbers = null, + bool $includeInvoicePeriodFlags = false + ): array { //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) $onlyCustomerNumbers = $onlyCustomerNumbers !== null @@ -1888,14 +1894,16 @@ class InvoicingPeriodRoute $draftOverlay['by_collection_id'] ?? [], $draftOverlay['by_customer_number'] ?? [], ); - $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { - return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( - $types, - $dateFrom, - $dateTo, - $onlyCustomerNumbers - ); - }, 'invoice_period_flags'); + if ($includeInvoicePeriodFlags) { + $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { + return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( + $types, + $dateFrom, + $dateTo, + $onlyCustomerNumbers + ); + }, 'invoice_period_flags'); + } return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index d6efafe6..4c1e4935 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -131,7 +131,7 @@ class departmentSelfserveVehicleConditionsRoute $lane_id = (int)self::getParameter('lane_id'); $reg = selfserve::standardize_registration((string)self::getParameter('reg')); - $lane = $this->assertLaneAccess($user, $lane_id, $has_global); + $lane = $this->assertLaneAccess($user, $lane_id); $customer_number = null; if (!$has_global && $has_own) { $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); @@ -193,7 +193,7 @@ class departmentSelfserveVehicleConditionsRoute $lane_id = (int)self::getParameter('lane_id'); $reg = selfserve::standardize_registration((string)self::getParameter('reg')); - $this->assertLaneAccess($user, $lane_id, $has_global); + $this->assertLaneAccess($user, $lane_id); $customer_number = null; if (!$has_global && $has_own) { $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); @@ -527,7 +527,7 @@ class departmentSelfserveVehicleConditionsRoute return $default; } - private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o + private function assertLaneAccess(object $user, int $laneId): department_lanes_o { global $response; @@ -536,11 +536,14 @@ class departmentSelfserveVehicleConditionsRoute $response->error('Department lane not found', 404); } - if ($hasGlobalPermission) { - $authorized_department_ids = $user->getGroup()->getDepartments(); - if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) { - $this->forbidDepartmentAccess((int)$lane->department->value()); - } + $lane_department_id = (int)$lane->department->value(); + $authorized_department_ids = array_values(array_filter( + array_map('intval', (array)$user->getGroup()->getDepartments()), + static fn(int $department_id): bool => $department_id > 0 + )); + + if (!in_array($lane_department_id, $authorized_department_ids, true)) { + $this->forbidDepartmentAccess($lane_department_id); } return $lane; diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index b03cd951..334e2bc4 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -17,7 +17,7 @@ class departmentsRoute { use route_t; - private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string + private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array { global $response; @@ -41,7 +41,7 @@ class departmentsRoute $filters['visible'] = 1; $filters['archived'] = $archived; - return $departments->array_to_filters($filters); + return $filters; } private static function isTruthyBooleanValue(mixed $value): bool diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index c0e6201e..b13d75de 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -198,7 +198,9 @@ class ordersRoute $po = $this->resolveOrderPoForBookingDefault( array_key_exists('po', $data) ? $data['po'] : null, array_key_exists('po', $data), - $bookingId + $bookingId, + (int)$data['customer_id'], + (int)$data['department_id'] ); $new_data = [ 'customer_id' => (int)$data['customer_id'], @@ -1251,14 +1253,20 @@ class ordersRoute } } - private function resolveOrderPoForBookingDefault(mixed $po, bool $poProvided, ?int $bookingId): ?string + private function resolveOrderPoForBookingDefault( + mixed $po, + bool $poProvided, + ?int $bookingId, + int $customerNumber, + int $departmentId + ): ?string { $currentPo = is_scalar($po) || $po === null ? trim((string)$po) : ''; if ($currentPo !== '') { return $currentPo; } - $bookingPo = $this->getBookingPoDefault($bookingId); + $bookingPo = $this->getBookingPoDefault($bookingId, $customerNumber, $departmentId); if ($bookingPo !== null) { return $bookingPo; } @@ -1273,7 +1281,11 @@ class ordersRoute return; } - $bookingPo = $this->getBookingPoDefault($bookingId ?? (int)($order->booking_id->value() ?? 0)); + $bookingPo = $this->getBookingPoDefault( + $bookingId ?? (int)($order->booking_id->value() ?? 0), + (int)$order->customer_id->value(), + (int)$order->department_id->value() + ); if ($bookingPo === null) { return; } @@ -1281,9 +1293,13 @@ class ordersRoute $order->po->set($bookingPo); } - private function getBookingPoDefault(?int $bookingId): ?string + private function getBookingPoDefault(?int $bookingId, int $customerNumber, int $departmentId): ?string { - if ($bookingId === null || $bookingId <= 0) { + if ($bookingId === null || $bookingId <= 0 || $customerNumber <= 0 || $departmentId <= 0) { + return null; + } + + if (!$this->canUseBookingPoDefault($customerNumber, $departmentId)) { return null; } @@ -1293,6 +1309,18 @@ class ordersRoute return null; } + if ((int)$booking->customer_number->value() !== $customerNumber) { + return null; + } + + if ((int)$booking->department->value() !== $departmentId) { + return null; + } + + if (trim((string)($booking->deleted_at->value() ?? '')) !== '') { + return null; + } + $bookingPo = trim((string)($booking->po->value() ?? '')); return $bookingPo !== '' ? $bookingPo : null; } catch (\Throwable) { @@ -1300,6 +1328,20 @@ class ordersRoute } } + private function canUseBookingPoDefault(int $customerNumber, int $departmentId): bool + { + try { + $user = (new authentication())->get_user(); + if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) { + return true; + } + + return $this->hasDepartmentAccess((string)$departmentId); + } catch (\Throwable) { + return false; + } + } + private function normalizeLegacyEditableFieldPayload(array $data, response $response): array { if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) { diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 2f4cb73a..8c68c24a 100644 --- a/services/nginx/app/tests/Api/DepartmentsApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -133,6 +133,20 @@ it('does not allow regular department listings to reveal archived departments th expect($departmentIds) ->toContain($activeDepartment['id']) ->not->toContain($archivedDepartment['id']); + + $response = api_client()->get('/departments?filters[name]=NOT%20NULL%2Carchived:1', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($departmentIds)->not->toContain($archivedDepartment['id']); }); it('rejects department listing when the permission is missing', function (): void { diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index 6e65d063..1560658c 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -102,6 +102,132 @@ it('creates orders through the orders endpoint', function (): void { api_fixtures()->cleanupDeleteById('orders', $orderId); }); + +it('defaults order PO only from a matching active booking', function (): void { + api_test_covers('POST /orders', 'security'); + api_test_covers('PUT /orders', 'security'); + + $department = api_fixtures()->createDepartment(['name' => 'Order Booking PO Department']); + $otherDepartment = api_fixtures()->createDepartment(['name' => 'Order Booking PO Other Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Customer']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Other Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Booking PO Cashier']); + $matchingBooking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'po' => 'MATCHING-BOOKING-PO', + ]); + $foreignBooking = api_fixtures()->createOrderBooking([ + 'customer_number' => $otherCustomer['customer_number'], + 'department' => $otherDepartment['id'], + 'po' => 'FOREIGN-BOOKING-PO', + ]); + $deletedBooking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'po' => 'DELETED-BOOKING-PO', + 'deleted_at' => date('Y-m-d H:i:s'), + ]); + $session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [ + 'customer_number' => $customer['customer_number'], + ]); + + $createResponse = api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-BOOKING-PO-MATCH', + 'notes' => 'Created with matching booking', + 'reg_1' => 'MATCHPO', + 'booking_id' => $matchingBooking['id'], + ], $session['headers']); + + $createResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $matchingOrderId = (int)($createResponse->data()['id'] ?? 0); + expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO'); + + $unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [ + 'customer_number' => $otherCustomer['customer_number'], + ]); + $unauthorizedResponse = api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-BOOKING-PO-UNAUTHORIZED', + 'notes' => 'Created without booking access', + 'reg_1' => 'NOAUTHPO', + 'booking_id' => $matchingBooking['id'], + ], $unauthorizedSession['headers']); + + $unauthorizedResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $unauthorizedOrderId = (int)($unauthorizedResponse->data()['id'] ?? 0); + expect($unauthorizedResponse->data()['po'] ?? null)->toBeNull(); + + $foreignResponse = api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-BOOKING-PO-FOREIGN', + 'notes' => 'Created with foreign booking', + 'reg_1' => 'FOREIGNPO', + 'booking_id' => $foreignBooking['id'], + ], $session['headers']); + + $foreignResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $foreignOrderId = (int)($foreignResponse->data()['id'] ?? 0); + expect($foreignResponse->data()['po'] ?? null)->toBeNull(); + + $deletedResponse = api_client()->post('/orders', [ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-BOOKING-PO-DELETED', + 'notes' => 'Created with deleted booking', + 'reg_1' => 'DELETEPO', + 'booking_id' => $deletedBooking['id'], + ], $session['headers']); + + $deletedResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $deletedOrderId = (int)($deletedResponse->data()['id'] ?? 0); + expect($deletedResponse->data()['po'] ?? null)->toBeNull(); + + $existingOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'ORDER-BOOKING-PO-UPDATE', + 'reg_1' => 'UPDATEPO', + ]); + + api_client()->put('/orders', [ + 'id' => $existingOrder['id'], + 'booking_id' => $foreignBooking['id'], + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $updatedRow = api_fixtures()->fetchRowById('orders', (int)$existingOrder['id']); + expect($updatedRow['po'] ?? null)->toBeNull(); + + api_fixtures()->cleanupDeleteById('orders', $matchingOrderId); + api_fixtures()->cleanupDeleteById('orders', $unauthorizedOrderId); + api_fixtures()->cleanupDeleteById('orders', $foreignOrderId); + api_fixtures()->cleanupDeleteById('orders', $deletedOrderId); +}); + it('rejects invalid order creation requests', function (): void { api_test_covers('POST /orders', 'failure'); diff --git a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php index 3ffd449e..76ed6b89 100644 --- a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php +++ b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php @@ -179,6 +179,59 @@ it('orders reference suggestions by match relevance before context and frequency expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']); }); +it('does not return vehicle reference suggestions outside the authorized department context', function (): void { + api_test_covers('GET /orders/reference-suggestions', 'security'); + + $authorizedDepartment = api_fixtures()->createDepartment(); + $otherDepartment = api_fixtures()->createDepartment(); + $authorizedCustomer = api_fixtures()->createUser(['display_name' => 'Authorized Reference Customer']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Tenant Reference Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Security Cashier']); + + api_fixtures()->createOrder([ + 'customer_id' => $authorizedCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $authorizedDepartment['id'], + 'reference' => 'SAFE-DEPARTMENT-REF', + 'reg_1' => 'SAFE1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $otherCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $otherDepartment['id'], + 'reference' => 'LEAK-ORDER-REF', + 'reg_1' => 'LEAK1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $otherCustomer['customer_number'], + 'type' => 53, + 'reg' => 'LEAK1', + 'reference' => 'LEAK-VEHICLE-REF', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $authorizedDepartment['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'LEAK', + 'department_id' => $authorizedDepartment['id'], + 'customer_id' => $otherCustomer['customer_number'], + 'reg_1' => 'LEAK1', + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $suggestions = $response->data(); + expect($suggestions)->toBeArray(); + expect(reference_suggestion_by_reference($suggestions, 'LEAK-VEHICLE-REF'))->toBeNull(); + expect(reference_suggestion_by_reference($suggestions, 'LEAK-ORDER-REF'))->toBeNull(); +}); + it('enforces authentication, list permission, and department access for reference suggestions', function (): void { api_test_covers('GET /orders/reference-suggestions', 'auth'); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 6279b39e..5c4330c9 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -653,6 +653,25 @@ it('does not report a price mismatch when a product-specific discount makes the expect($flags)->toBe([]); }); +it('does not treat subset primary vehicle product names as equivalent', function (): void { + expect(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [5, 'Forvogn', 6, 'Forvogn med hænger'])) + ->toBeFalse() + ->and(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [10, 'Indvendig vask Kassevogn', 11, 'Kassevogn'])) + ->toBeTrue(); +}); + +it('rebuilds automatic invoice period data on cache misses instead of hiding warnings', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('$flags = $this->buildAutomaticFlagsForPeriod($dateFrom, $dateTo);'); + expect($content)->toContain('$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);'); + expect($content)->toContain('cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows)'); + expect($content)->not->toContain('enqueue_invoice_period_warming($dateFrom, $dateTo)'); +}); + it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void { $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index 62becc2a..99b856cb 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -45,12 +45,26 @@ it('streams the main period response instead of encoding the full payload at onc $content = (string)$content; expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void') - ->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);') + ->and($content)->toContain("$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');") + ->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);') ->and($content)->toContain('self::streamInvoicingPeriodResponse($period);') ->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)') ->and($content)->toContain('echo self::jsonFragment($customer);'); }); +it('only includes invoice period flags when the list permission is granted', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content) + ->toContain("$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');") + ->and($content)->toContain('bool $includeInvoicePeriodFlags = false') + ->and($content)->toContain('if ($includeInvoicePeriodFlags) {') + ->and($content)->toContain('applyFlagsToPeriodTypes('); +}); + it('maps batched period transaction rows to the legacy transaction response shape', function (): void { $reflection = new ReflectionClass(InvoicingPeriodRoute::class); $method = $reflection->getMethod('constructTransactionObjectFromPeriodRow'); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index 92b695c1..82b5ad4d 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -83,6 +83,7 @@ it('normalizes app-specific release gate auto-sync metadata', function (): void 'channel_slug' => 'stable', 'route_slug' => 'master', 'app' => 'api', + 'apps' => ['api'], 'repository' => 'copenhagentruckwash/api', 'branch' => 'master', 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', @@ -90,6 +91,11 @@ it('normalizes app-specific release gate auto-sync metadata', function (): void 'auto_sync' => true, ]); + $fullStackGate = $normalizeGate->invoke($manager, [ + 'channel_slug' => 'stable', + ], ['slug' => 'stable']); + + expect($fullStackGate['apps'])->toBe(['frontend', 'api']); expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue(); expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue(); expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue(); @@ -899,6 +905,7 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($manager)->toContain('createBundle'); expect($manager)->toContain('deployBundle'); expect($manager)->toContain('promoteBundle'); + expect($manager)->toContain('if ($apiVersionId !== null)'); expect($manager)->toContain('setChannelBundle'); expect($manager)->toContain('searchAssignmentSubjects'); expect($manager)->toContain('publicAssignmentSubjectSuggestion'); @@ -975,6 +982,7 @@ it('defines release manager schema, routes, permissions, and system-status integ expect($manager)->toContain('channelSlugForRoute'); expect($manager)->toContain('syncChannel'); expect($manager)->toContain('runReleaseTest'); + expect($manager)->toContain("'apps' => \$this->releaseTestAppsFromInput(\$input)"); expect($manager)->toContain('releaseTestAppsFromInput'); expect($manager)->toContain('release_operation_runs'); expect($manager)->toContain('active_channel_app_key'); @@ -1349,7 +1357,7 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch ]))->toBe('https://gateway.example.test/beta/frontend'); }); -it('exposes release version git commit metadata for runtime channel cards', function (): void { +it('redacts GitHub access metadata from public release versions', function (): void { $manager = new release_manager(); $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); $publicVersion->setAccessible(true); @@ -1367,20 +1375,29 @@ it('exposes release version git commit metadata for runtime channel cards', func 'deployed_url' => null, 'status' => 'active', 'metadata_json' => json_encode([ + 'commit_mode' => 'specific', 'github_access' => [ 'commit' => [ 'sha' => 'c0ffee0000001111222233334444555566667777', + 'message' => 'Private implementation detail', + 'author_name' => 'Release Bot', 'authored_at' => '2026-05-19T10:15:00Z', ], + 'commit_authored_at' => '2026-05-19T10:15:00Z', ], + 'bundle_member' => true, ]), 'created_at' => '2026-05-19 10:10:00', 'deployed_at' => '2026-05-19 10:20:00', ]); expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777'); - expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777'); - expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z'); + expect($version['commit'])->toBeNull(); + expect($version['commit_authored_at'])->toBeNull(); + expect($version['metadata'])->toBe([ + 'commit_mode' => 'specific', + 'bundle_member' => true, + ]); expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); }); diff --git a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php index fa02c08b..fc3261bd 100644 --- a/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php +++ b/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php @@ -385,6 +385,30 @@ it('caps AI-driven expanded terms to prevent query amplification', function (): expect($maxLen)->toBeLessThanOrEqual(64); }); +it('does not expand associations for own-only entity types', function (): void { + $parser = new FakeSystemSearchIntentParser(); + $service = new TestableSystemSearchService($parser, [ + [[ + 'entity_type' => 'customers', + 'entity_id' => '10', + 'title' => 'Acme', + 'customer_number' => 1234, + 'score' => 80, + ]], + [], + ]); + + $service->search([ + 'query' => 'acme', + 'allowed_types' => ['customers', 'orders'], + 'own_only_types' => ['orders'], + 'own_customer_number' => 4444, + 'include_associations' => true, + ]); + + expect(count($service->lexicalCalls))->toBe(1); +}); + it('expands danish discount wording into lexical discount synonyms', function (): void { $parser = new FakeSystemSearchIntentParser(); $service = new TestableSystemSearchService($parser, [[ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php new file mode 100644 index 00000000..b9943bc8 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php @@ -0,0 +1,58 @@ +{$method}('{$path}'"); + if ($start === false) { + throw new RuntimeException("Route block not found: {$method} {$path}"); + } + + $nextComment = strpos($route, "\n /**", $start + 1); + if ($nextComment === false) { + return substr($route, $start); + } + + return substr($route, $start, $nextComment - $start); +} + +function selfserve_eligibility_method_block(string $route, string $signature): string +{ + $start = strpos($route, $signature); + if ($start === false) { + throw new RuntimeException("Method not found: {$signature}"); + } + + $nextMethod = strpos($route, "\n private function ", $start + strlen($signature)); + if ($nextMethod === false) { + return substr($route, $start); + } + + return substr($route, $start, $nextMethod - $start); +} + +it('authorizes customer eligibility preview lanes before returning task attachments', function (): void { + $route = selfserve_eligibility_route_source(); + $allowedBlock = selfserve_eligibility_route_block($route, 'get', '/department/selfserve/vehicle/allowed'); + $assertBlock = selfserve_eligibility_method_block($route, 'private function assertLaneAccess(object $user, int $laneId): department_lanes_o'); + + expect($allowedBlock)->toContain('$lane = $this->assertLaneAccess($user, $lane_id);') + ->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)') + ->and(strpos($allowedBlock, '$lane = $this->assertLaneAccess($user, $lane_id);')) + ->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)')); + + expect($assertBlock)->toContain('$authorized_department_ids = array_values(array_filter(') + ->and($assertBlock)->toContain('array_map(\'intval\', (array)$user->getGroup()->getDepartments())') + ->and($assertBlock)->toContain('if (!in_array($lane_department_id, $authorized_department_ids, true))') + ->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);') + ->and($assertBlock)->not->toContain('if ($hasGlobalPermission)'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartRaceLockWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartRaceLockWiringTest.php new file mode 100644 index 00000000..ee91df43 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartRaceLockWiringTest.php @@ -0,0 +1,28 @@ +not->toBeFalse(); + expect($commandTrait)->toContain('set_if_absent_with_expiration'); + + $startCaseOffset = strpos($commandTrait, 'case selfserve_lane_command::START:'); + expect($startCaseOffset)->not->toBeFalse(); + + $startCase = substr($commandTrait, (int)$startCaseOffset, 3500); + $lockOffset = strpos($startCase, '$start_lock_token = $this->acquireLaneStartCommandLock();'); + $occupiedOffset = strpos($startCase, '$this->setLaneStatus(selfserve_lane_status::OCCUPIED);'); + $openOffset = strpos($startCase, '$this->openEntrancePortForWashStart();'); + $finallyOffset = strpos($startCase, '} finally {'); + $releaseOffset = strpos($startCase, '$this->releaseLaneStartCommandLock($start_lock_token);'); + + expect($lockOffset)->not->toBeFalse() + ->and($occupiedOffset)->not->toBeFalse() + ->and($openOffset)->not->toBeFalse() + ->and($finallyOffset)->not->toBeFalse() + ->and($releaseOffset)->not->toBeFalse() + ->and($lockOffset)->toBeLessThan($occupiedOffset) + ->and($occupiedOffset)->toBeLessThan($openOffset) + ->and($openOffset)->toBeLessThan($finallyOffset) + ->and($finallyOffset)->toBeLessThan($releaseOffset); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php index d07611a7..49217bba 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -1321,7 +1321,7 @@ it('truncates path outcome projection when the state cap is reached', function ( ->and($projection['warnings'][0])->toContain('truncated at 2 explored state'); }); -it('returns complete terminal path results for wide question trees and reports progress', function (): void { +it('applies default caps for wide question trees and reports progress', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = function (array $overrides): array { $answers = []; @@ -1380,17 +1380,18 @@ it('returns complete terminal path results for wide question trees and reports p }, ]); - expect($projection['truncated'])->toBeFalse() - ->and($projection['summary']['state_count'])->toBe(8191) + expect($projection['truncated'])->toBeTrue() + ->and($projection['summary']['state_count'])->toBe(2048) ->and($projection['summary']['question_count'])->toBe(12) - ->and($projection['summary']['terminal_path_count'])->toBe(4096) + ->and($projection['summary']['terminal_path_count'])->toBe(1023) ->and($projection['summary']['outcome_count'])->toBe(2) - ->and($projection['summary']['path_sample_count'])->toBe(4096) + ->and($projection['summary']['path_sample_count'])->toBe(200) ->and($projection['progress']['complete'])->toBeTrue() ->and($projection['progress']['percent'])->toBe(100) - ->and($projection['paths'])->toHaveCount(4096) + ->and($projection['paths'])->toHaveCount(200) ->and($projection['paths'][0]['answers'])->toHaveCount(12) ->and($projection['paths'][0]['result'])->toBe('Allowed') + ->and($projection['warnings'][0])->toContain('truncated at 2048 explored state') ->and($progressEvents)->not->toBeEmpty() ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0) ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0); diff --git a/test/orderBookingsPost.http b/test/orderBookingsPost.http index 83d7fba2..67713d6f 100644 --- a/test/orderBookingsPost.http +++ b/test/orderBookingsPost.http @@ -27,7 +27,7 @@ Authorization: Bearer {{ADMIN_BEARER_TOKEN}} POST https://api.truckwash.io:4433/collected-invoices/move-multiple Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { "order_ids": [54518, 48782, 48744], @@ -39,7 +39,7 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206 POST https://api.truckwash.io:4433/collected-invoices/move-multiple/registration-numbers Accept: application/json Content-Type: application/json -Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Authorization: Bearer {{ADMIN_BEARER_TOKEN}} { "registration_numbers": ["DC29870",