Compare commits

..
Author SHA1 Message Date
Jeppe B 5e6b340f8c Use compose broker URL for edge gateway smoke 2026-05-29 15:29:41 +02:00
Jeppe B 04e47a2e6d Start all PHP upstreams for edge gateway smoke 2026-05-29 15:10:49 +02:00
Jeppe B 572f5027d6 Run edge gateway smoke inside compose network 2026-05-29 14:56:36 +02:00
Jeppe B 235e0268c2 Fix backend CI gate failures 2026-05-29 14:36:18 +02:00
Jeppe B 65d639853b Skip Qodana when cloud token is unavailable 2026-05-28 23:44:07 +02:00
Jeppe B e856bbffec Trigger backend master test gates 2026-05-28 23:35:12 +02:00
Jeppe Bundgaard 3ee5b789ce Update setMachineRelayStatusHard method to use MACHINE_PROGRAM_PICKER constant for relay status setting 2026-05-28 21:08:37 +02:00
Jeppe Bundgaard 7f5722ff75 Add exception handling for cleaner relay activation in self-serve lanes
- Include `\Throwable` in docstring for better error documentation.
- Implement `turnOnCleanerRelayForWashStart` in the wash start process.
2026-05-28 20:40:08 +02:00
Jeppe B 50b596af39 Merge pull request #157 from copenhagentruckwash/fix-issues-and-verify-with-tests
Fix test gateway Windows config paths
2026-05-28 19:39:51 +02:00
Jeppe B af06c4d81e Merge pull request #160 from copenhagentruckwash/copilot/fix-qodana-workflow-failure
Fix Qodana failure on self-hosted runner by trusting workspace as Git safe.directory
2026-05-28 19:39:25 +02:00
Jeppe B 41ed692299 Merge pull request #159 from copenhagentruckwash/fix-subuser-token-permission-bypass
Restrict replication endpoints to classic users
2026-05-28 19:37:54 +02:00
copilot-swe-agent[bot] 31214f0af0 fix: mark workspace as git safe directory before qodana 2026-05-28 17:34:58 +00:00
Jeppe B aceaa6b957 Fix Qodana workflow and Windows-style test gateway paths
Update the Qodana workflow to use an available action version and avoid cloud-token failures when the secret is absent. Keep the test gateway path resolver using Windows path semantics for Windows-style inputs.
2026-05-28 19:33:02 +02:00
copilot-swe-agent[bot] cd0e0f0e61 Initial plan 2026-05-28 17:30:45 +00:00
copilot-swe-agent[bot] 0db6b5269d Merge origin/master and resolve replication route conflict 2026-05-28 17:29:12 +00:00
Jeppe B 3fb1eb9644 Restrict replication endpoints to classic users 2026-05-28 19:25:59 +02:00
Jeppe B 76dfcd70d1 Merge pull request #158 from copenhagentruckwash/fix-authorization-bypass-in-self-serve-lanes
Harden self-serve lane mutation authorization
2026-05-28 19:25:01 +02:00
Jeppe B b13abe0d30 Harden self-serve lane mutation authorization 2026-05-28 19:23:31 +02:00
Jeppe B 270e5b970f Support Windows-style test gateway paths
Resolve test gateway paths with the Windows path implementation when inputs use Windows-style syntax. This preserves the existing runnable script test suite without adding Windows-only tests.
2026-05-28 19:16:52 +02:00
Jeppe B 5dac3211ff Fix test gateway Windows config paths
### Motivation
- Tests that resolve the test gateway config directory were failing on Windows-style paths because the code always used the POSIX `path` module, producing mismatched separators.
- Preserve Windows path semantics when `rootDir` or an explicit config path uses Windows syntax while leaving POSIX behavior unchanged.

### Description
- Add `usesWindowsPathSyntax` and `pathForInputs` helpers to detect Windows-style paths and select `path.win32` when needed.
- Use the selected `pathModule` in `resolveConfigDirectory` to call `resolve`/`join` so Windows roots or explicit Windows dirs keep correct separators.
- Change is confined to `scripts/test-gateway.mjs` and does not alter other runtime behavior.

### Testing
- Ran `node --test scripts/*.test.mjs` which initially showed one failing path test and after the fix completed with all tests passing (`14` passed, `0` failed).
- Ran `npm test` in `services/edge-agent` and `services/edge-broker`, both suites passed (`18` and `23` tests respectively).
- Ran `node scripts/sync-ai-workflow.mjs --check` and `git diff --check` which both succeeded.
2026-05-28 19:11:58 +02:00
Jeppe B 4d91fc8ead Fix test gateway Windows config paths 2026-05-28 19:00:31 +02:00
Jeppe B 893ed1bda5 Fix PHP CI legacy and edge gateway tests
- Match self-serve legacy test double invoice signature.
- Wait for the edge gateway integration database before bootstrapping schema.
2026-05-28 18:03:30 +02:00
Jeppe Bundgaard 90ebec84bf Add PHP CI test script and optimize Redis config in tests
- Introduced a PHP CI test script for managing test suites.
- Consolidated Redis configuration retrieval.
- Optimized test fixture queries with dynamic object type assignments.
2026-05-28 17:58:06 +02:00
Jeppe Bundgaard bdf2a787d6 Merge remote-tracking branch 'origin/master' 2026-05-28 17:33:10 +02:00
Jeppe Bundgaard f8c254607d Implement Lane Status Audit and Comprehensive Self-Serve API Enhancements
- Introduced `machine_status_audit` in self-serve lanes for tracking changes.
- Added new methods to handle audit data including `setLaneStatusAudit` and `getMachineStatusAudit`.
- Enhanced API tests to include legacy Redis constant checks and validated comprehensive self-serve invoice creation.
- Updated department lanes to reflect audit logs in their responses.
2026-05-28 17:27:25 +02:00
Jeppe B 20eb92891a Avoid empty self-serve invoice orders
Only create the invoice order context when elapsed minute billing has a positive quantity. This preserves automatic-mode included-minute reduction without leaving an empty order id on the lane.

Tests:
- bash scripts/php-ci-test.sh unit
2026-05-28 17:21:36 +02:00
Jeppe Bundgaard 4cfe906f55 Update invoice function in selfserve_lane_command_t to accept command arguments and add necessary requires in selfserve_lane_invoice_t. 2026-05-28 16:36:09 +02:00
Jeppe Bundgaard 184ea1ca6c Enhance invoice and self-serve logic with subuser support
- Add subuser ID management to `selfserve_lane_command_arguments`.
- Update `invoice` function to include optional command arguments.
- Attach metadata to orders with self-serve and subuser details.
- Introduce `OTHER_TYPE_SELF_SERVE_WASH` in `attachment_content`.
2026-05-28 16:11:14 +02:00
Jeppe Bundgaard ae3657e7aa Add new API tests for order item note requirements, subuser route updates, and department lane status management
- Introduced tests for validating note requirements on order items.
- Updated subuser route management contract tests with new route coverage.
- Added endpoints to manage department lane and self-serve lane statuses, with associated tests.
2026-05-28 16:06:14 +02:00
Jeppe Bundgaard 54de2e5674 Add fake classes for relay logic and refactor relay shutdown without pre-checking status
Introduce helper classes `SelfserveWashCompletionRelayValueFake`, `SelfserveWashCompletionDepartmentLaneFake`, `SelfserveWashCompletionRelayLaneFake`, and `SelfserveWashCompletionFlowHarness` to simulate relay logic for unit tests. Refactor `turnOffRelayIfConfiguredAndOn` to `turnOffRelayIfConfigured`, removing relay status pre-check for cleaner and machine relays when completing a wash session, and test associated relay actions.
2026-05-27 19:30:31 +02:00
Jeppe Bundgaard eef436d44b Add tests for subuser password validation and grant permission normalization
Introduce unit and API tests for subuser password policies ensuring compliance with complexity requirements. Normalize subuser grant permission handling for consistency, including support for legacy zero permissions.
2026-05-27 19:17:19 +02:00
Jeppe Bundgaard b7aeb11801 Add department_selfserve_path_confirmations table and enhance PingApiTest
Introduce a new database table `department_selfserve_path_confirmations` to store path confirmations related to department configurations. Update `PingApiTest` to verify additional keys, ensuring `backend_version` and `api_commit_sha` are checked in the response.
2026-05-27 17:35:16 +02:00
Jeppe Bundgaard d52ceb8513 Add robust release update and API health checks
This commit introduces a release update mechanism, including candidate detection, asset pre-downloading, and installation workflows with proper state management. Additionally, it implements API health checks both for successful and failure scenarios and adds related unit and e2e tests for enhanced reliability.
2026-05-27 13:24:15 +02:00
Jeppe Bundgaard 1504f1b116 Add tests for ReleaseManager's handling of production database targets, service set statuses, data target detection, and beta release bundle policies. 2026-05-26 17:28:12 +02:00
Jeppe Bundgaard 78462f3ae4 Integrate cors_policy class to standardize CORS handling, refactor optionsRoute to use it, and add unit tests for CORS and Self-Serve Lane Access functionalities. 2026-05-26 16:35:02 +02:00
Jeppe Bundgaard 6ff6ce9b48 Add tests for automatic path-routed release target preparation and application target handling failures in ReleaseManager 2026-05-26 15:11:27 +02:00
Jeppe Bundgaard bc7c0280f2 Add .gitattributes for binary files and extend order booking update tests. Enhance dynamic image and routing logic with program_picker support. 2026-05-26 14:04:27 +02:00
78 changed files with 19057 additions and 3343 deletions
+3
View File
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
# Any blank debug value falls back to the live value above.
CONFIG_DB_DEBUG_HOST=mysql-debug
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+19 -1
View File
@@ -20,14 +20,32 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
fetch-depth: 0 # a full history is required for pull request analysis
- name: Mark repository as safe for Git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Prepare Qodana cache directories
run: |
mkdir -p "${RUNNER_TEMP}/qodana/caches"
mkdir -p "${RUNNER_TEMP}/qodana/results"
- name: Detect Qodana Cloud token
id: qodana-token
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
if [ -n "${QODANA_TOKEN:-}" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.3
if: ${{ steps.qodana-token.outputs.present == 'true' }}
uses: JetBrains/qodana-action@v2026.1
with:
pr-mode: false
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }}
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
+27 -1
View File
@@ -144,6 +144,7 @@ jobs:
set -euo pipefail
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -151,7 +152,7 @@ jobs:
node-version: 22
- name: Boot local stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout
run: >
@@ -240,6 +241,7 @@ jobs:
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace \
node:22-alpine \
@@ -253,3 +255,27 @@ jobs:
- name: Tear down local stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Record Release Manager API gate
run: |
set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+1 -1
View File
@@ -10,7 +10,7 @@ $CONFIG_DB = [
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
$ECONOMIC_API = [
'app_access_grant' => '', // Economic API access grant token (1)
'app_access_grant2' => '', // Economic API access grant token (2)
+209 -5
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding
description: Branding options management
- name: Roles
@@ -4030,13 +4032,15 @@ paths:
- name: buttons
in: query
required: false
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
schema:
oneOf:
- type: string
- type: array
items:
type: integer
oneOf:
- type: integer
- type: string
- name: current_step
in: query
required: false
@@ -5844,7 +5848,7 @@ paths:
reference: {type: string}
po: {type: string}
pickup: {type: boolean}
order_id: {type: integer}
order_id: {type: integer, nullable: true}
items:
type: array
items:
@@ -7701,7 +7705,15 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema: {}
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
/worker/debug:
get:
@@ -8953,6 +8965,10 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -9003,6 +9019,9 @@ paths:
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
derived from the tasks currently shown to the user after answering the self-serve questions.
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
confirming a wash start.
operationId: setSelfServeLaneAllowedServices
requestBody:
required: true
@@ -9446,7 +9465,9 @@ paths:
description: |
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
enabled; an explicit call to this endpoint is required.
enabled; an explicit call to this endpoint is required. Operator callers require
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
operationId: enableSelfServeLaneMachineRelay
@@ -11982,6 +12003,189 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/superuser/releases/operations:
get:
tags:
- Release Manager
summary: List release operation runs
operationId: listReleaseOperations
parameters:
- in: query
name: channel_id
schema:
type: integer
- in: query
name: operation_type
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
responses:
'200':
description: Release operation runs
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/releases/operations/{id}:
get:
tags:
- Release Manager
summary: Get release operation details
operationId: getReleaseOperation
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Release operation details
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/releases/test-runs:
post:
tags:
- Release Manager
summary: Run Release Manager diagnostics
operationId: runReleaseTest
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Release test operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/channels/{id}/sync:
post:
tags:
- Release Manager
summary: Sync latest branch commits into a release channel
operationId: syncReleaseChannel
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'202':
description: Channel sync operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/issues/actions:
post:
tags:
- Release Manager
summary: Run a Release Manager issue action
operationId: runReleaseIssueAction
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issue_key:
type: string
action_id:
type: string
inputs:
type: object
additionalProperties: true
confirm:
type: boolean
additionalProperties: true
responses:
'200':
description: Issue action result
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
BearerAuth:
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+8 -2
View File
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
@@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
}
function shouldSkipComposeUp() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim());
}
function collectMessages(rows) {
return Array.isArray(rows)
? rows
@@ -570,7 +574,9 @@ async function main() {
let runnerNetworkAttached = false;
try {
await ensureComposeServices(rootDir, composeProject);
if (!shouldSkipComposeUp()) {
await ensureComposeServices(rootDir, composeProject);
}
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
process.stdout.write(`Using API base URL ${baseUrl}\n`);
+15 -3
View File
@@ -25,6 +25,16 @@ function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
function usesWindowsPathSyntax(filePath) {
return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\");
}
function pathForInputs(...filePaths) {
const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || "")));
return hasWindowsPath ? path.win32 : path;
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
@@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) {
return explicit;
}
return path.basename(rootDir);
return pathForInputs(rootDir).basename(rootDir);
}
export function resolveComposeNetworkName(rootDir, env = process.env) {
@@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) {
}
export function resolveConfigDirectory(rootDir, explicitDir = null) {
const pathModule = pathForInputs(rootDir, explicitDir);
if (explicitDir) {
return path.resolve(rootDir, explicitDir);
return pathModule.resolve(rootDir, explicitDir);
}
return path.join(rootDir, ".tmp", "test-gateway");
return pathModule.join(rootDir, ".tmp", "test-gateway");
}
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
@@ -100,6 +100,28 @@ class coolify_api_client
}
public function updateServiceEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
public function updateApplicationEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
private static function bulkEnvData(array $env): array
{
$data = [];
foreach ($env as $key => $value) {
@@ -113,11 +135,7 @@ class coolify_api_client
];
}
if ($data === []) {
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
return $data;
}
public function deployResource(string $uuid, bool $force = false): array
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace classes;
class cors_policy
{
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [
'https://truckwash.io',
'https://www.truckwash.io',
'https://api.truckwash.io',
'https://api.truckwash.io:4433',
'https://api-v2.truckwash.io',
'https://web.truckwash.dk',
'https://api.truckwash.dk',
'https://truckwash.dk',
'https://www.truckwash.dk',
'https://staging.truckwash.io',
'http://localhost',
'https://localhost',
'http://localhost:4433',
'https://localhost:4433',
'https://twdev.jeppeb.dk',
'http://localhost:5173',
];
public static function normalizeOrigin(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || $value === '*') {
return $value;
}
if (preg_match('#^https?://#i', $value) !== 1) {
return '';
}
$parts = parse_url($value);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return '';
}
$scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return $scheme . '://' . $host . $port;
}
/**
* @return array<int,string>
*/
public static function requiredAllowedOrigins(): array
{
return self::REQUIRED_ALLOWED_ORIGINS;
}
/**
* @return array<int,string>
*/
public static function allowedOrigins(string $corsConfig): array
{
$origins = [];
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
if ($configuredOrigin === '*') {
return ['*'];
}
$origin = self::normalizeOrigin($configuredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
$origin = self::normalizeOrigin($requiredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
return array_keys($origins);
}
public static function withRequiredOrigins(string $corsConfig): string
{
$allowedOrigins = self::allowedOrigins($corsConfig);
if ($allowedOrigins === ['*']) {
return '*';
}
return implode(',', $allowedOrigins);
}
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || $origin === '*') {
return false;
}
$allowedOrigins = self::allowedOrigins($corsConfig);
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
}
/**
* @return array<string,string>
*/
public static function responseHeaders(?string $origin, string $corsConfig): array
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return [];
}
return [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Vary' => 'Origin',
];
}
/**
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
*/
public static function preflightResponse(?string $origin, string $corsConfig): array
{
$headers = self::responseHeaders($origin, $corsConfig);
if ($headers === []) {
return [
'allowed' => false,
'status' => 403,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
];
}
$headers['Content-Type'] = 'application/json';
return [
'allowed' => true,
'status' => 200,
'headers' => $headers,
'body' => '',
];
}
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
{
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
if ($headers === []) {
return false;
}
self::emitHeaders($headers);
return true;
}
/**
* @param array<string,string> $headers
*/
public static function emitHeaders(array $headers): void
{
foreach ($headers as $name => $value) {
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
}
}
/**
* @return array<int,string>
*/
private static function splitOrigins(string $corsConfig): array
{
return array_values(array_filter(
array_map('trim', explode(',', $corsConfig)),
static fn(string $origin): bool => $origin !== ''
));
}
}
File diff suppressed because it is too large Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
@@ -157,6 +186,7 @@ class release_manager_schema_bootstrap
bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL,
active_channel_app_key VARCHAR(96) NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
@@ -297,6 +327,47 @@ class release_manager_schema_bootstrap
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_runs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(64) NOT NULL,
subject_type VARCHAR(64) NULL,
subject_id VARCHAR(128) NULL,
channel_id BIGINT UNSIGNED NULL,
app VARCHAR(16) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
title VARCHAR(255) NULL,
summary TEXT NULL,
solution_hint TEXT NULL,
actor_user_id INT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_runs_channel (channel_id, created_at),
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
INDEX idx_release_operation_runs_type_status (operation_type, status),
INDEX idx_release_operation_runs_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_run_id BIGINT UNSIGNED NOT NULL,
step_key VARCHAR(64) NOT NULL,
label VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
message TEXT NULL,
diagnostic TEXT NULL,
solution_hint TEXT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_steps_run (operation_run_id, id),
INDEX idx_release_operation_steps_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
@@ -321,6 +392,7 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
@@ -336,9 +408,12 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
@@ -362,9 +437,12 @@ class release_manager_schema_bootstrap
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
'release_operation_runs',
'release_operation_steps',
'release_timeline_sessions',
'release_timeline_events',
] as $table) {
@@ -457,4 +535,23 @@ class release_manager_schema_bootstrap
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
}
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
}
}
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
lane_id INT NULL,
vehicle_type_id INT NULL,
config_version_id INT NULL,
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
path_signature VARCHAR(128) NOT NULL,
result_signature VARCHAR(128) NOT NULL,
answers_json JSON NOT NULL,
result_json JSON NOT NULL,
scope_json JSON NULL,
confirmed_by INT NULL,
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
stale_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
+7 -19
View File
@@ -10,30 +10,18 @@ const WD = __DIR__;
require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php';
require_once __DIR__ . '/classes/cors_policy.php';
/** CORS */
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
}
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
header('Content-Type: application/json');
http_response_code(200);
exit;
}
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
\classes\cors_policy::emitHeaders($preflight['headers']);
http_response_code($preflight['status']);
echo $preflight['body'];
exit;
}
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
/** Debug */
if ($DEBUG) {
ini_set('display_errors', 1);
@@ -5,6 +5,7 @@ namespace attachments\helpers;
class attachment_content
{
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
public ?string $image; // Used to store the attachment object name, in the attachment store.
public ?string $document; // Used to store the attachment object name, in the attachment store.
public ?attachment_relation $relation; // Used to store the attachment relation object.
@@ -49,4 +50,4 @@ class attachment_content
$this->relation = $relation;
return $this;
}
}
}
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
const BUTTON_RESET = 'reset';
const BUTTON_START = 'start';
const BUTTON_PROGRAM_PICKER = 'program_picker';
// Thumb
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
public int $thumb_size = 1550; // height and width of the thumb
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
$this->drawProgramWheel();
$this->drawThumb();
$this->drawStepThumb();
$deferredStartButtons = $this->drawHighlightedButtonSequence();
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
$this->drawDeferredHighlightedButtons($deferredStartButtons);
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
if (is_string($button)) {
$trimmed = trim($button);
$specialButton = strtolower($trimmed);
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
return $specialButton;
}
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
private function getHighlightedButtonCoordinates(int|string $button): ?array
{
if ($button === self::BUTTON_PROGRAM_PICKER) {
return $this->getProgramPickerStepCoordinates();
}
if ($button === self::BUTTON_RESET) {
return [
'x' => 1736,
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
}
private function getProgramPickerStepCoordinates(): array
{
$thumbX = 650;
$thumbY = 1290;
$stepSize = 350;
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
$angle = $this->getRotationThumbPosition();
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
$rad = deg2rad($angle);
return [
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
'size' => $stepSize,
];
}
/**
* @throws \Exception
*/
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
{
public ?string $license_plate = null;
public ?int $customer_number = null;
public ?int $subuser_id = null;
public bool $defer_relay_side_effects = false;
/**
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
return $this;
}
public function setSubuserId(?int $subuser_id): self
{
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{
$this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
if (array_key_exists('customer_number', $params)) {
$this->setCustomerNumber($params['customer_number']);
}
if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
}
if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'],
File diff suppressed because it is too large Load Diff
@@ -273,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return;
}
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
return;
}
try {
$status = $lane->getRelayStatus($relay);
if ((bool)($status['on'] ?? false) !== true) {
return;
}
} catch (\Throwable) {
// If relay status can't be read, still attempt turn-off as best effort.
}
try {
$lane->setRelayStatusHard($relay, false);
} catch (\Throwable) {
@@ -723,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
{
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane);
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
'lane_id' => $laneId,
@@ -841,6 +832,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
$decisions = $this->buildDebugDecisions(
$questions,
$conditions,
$rules,
$tasks,
$actions,
(array)($hardware['signal_timeline'] ?? []),
$dynamicImageButtons
);
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
@@ -871,6 +872,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'rules' => $rules,
'tasks' => $tasks,
'actions' => $actions,
'dynamic_image_buttons' => $dynamicImageButtons,
'decisions' => $decisions,
'hardware' => $hardware,
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
'graph_annotations' => $annotations,
@@ -981,6 +984,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
: [];
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
: [];
$items = [];
foreach ($questions as $question) {
@@ -992,20 +998,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$visible = isset($visibleIds[$questionId]);
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
$causes = [];
if ($gateId !== null) {
$causes[] = $this->debugCause(
'condition',
$gateId,
$conditionLabel,
true,
($conditionResults[$gateId] ?? null),
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
);
}
if ($visible && $answer === null) {
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
}
$reason = $visible
? ($answer === null
? 'Question ' . $label . ' is visible but has no answer.'
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
$items[] = [
'kind' => 'question',
'id' => $questionId,
'node_id' => 'question:' . $questionId,
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
'node_ids' => ['question:' . $questionId],
'label' => $label,
'visible' => $visible,
'state' => $state,
'answer' => $answer,
'answer_source' => $sources[$questionId] ?? 'missing',
'condition_id' => $gateId,
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
'reason' => $visible
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
: 'Visibility condition did not pass.',
'condition' => $conditionLabel,
'gate_satisfied' => $gateSatisfied,
'reason' => $reason,
'causes' => $causes,
'order_priority' => (int)($question['order_priority'] ?? 0),
];
}
@@ -1036,19 +1065,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
$reason = $invalidReference
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
: ($satisfied
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
$items[] = [
'kind' => 'rule',
'id' => $ruleId,
'node_id' => 'rule:' . $ruleId,
'node_ids' => ['rule:' . $ruleId],
'condition_id' => (int)($rule['condition_id'] ?? 0),
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
'label' => $label,
'type' => (string)($rule['type'] ?? ''),
'object_type' => $objectType,
'object_id' => $objectId,
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
'object_label' => $objectLabel,
'actual_value' => $actual,
'satisfied' => $satisfied,
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
'invalid_reference' => $invalidReference,
'reason' => $reason,
'causes' => [
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
],
];
}
@@ -1082,10 +1125,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
$causes = [];
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
if ($failedExpressionCause !== null) {
$causes[] = $failedExpressionCause;
} elseif (!$result) {
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
if (($rule['satisfied'] ?? false) !== true) {
foreach ((array)($rule['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
break;
}
}
}
$items[] = [
'kind' => 'condition',
'id' => $conditionId,
'node_id' => 'condition:' . $conditionId,
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
'node_ids' => ['condition:' . $conditionId],
'label' => $label,
'result' => $result,
'state' => $result ? 'passed' : 'failed',
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
@@ -1098,6 +1160,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'reason' => in_array($conditionId, $cycleIds, true)
? 'Condition dependency cycle detected.'
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
'causes' => $causes,
];
}
@@ -1130,6 +1193,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
}
}
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['condition_results']
: [];
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
? (array)$snapshot['debug_candidates']['visible_answers']
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
$items = [];
@@ -1149,25 +1218,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$active = isset($activeIds[$taskId]);
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
$items[] = [
'kind' => 'task',
'id' => $taskId,
'node_id' => 'task:' . $taskId,
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
'node_ids' => ['task:' . $taskId],
'label' => $label,
'description' => (string)($task['description'] ?? ''),
'active' => $active,
'state' => $active ? 'active' : 'blocked',
'gate_type' => $gateType,
'gate_ref_id' => $gateRefId,
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
'gate_ref_label' => $gateRefLabel,
'gate_satisfied' => $gateSatisfied,
'services' => $services,
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
'attachments' => $taskAttachments,
'relay_bindings' => $bindings,
'order_priority' => (int)($task['order_priority'] ?? 0),
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
'reason' => $gateDecision['reason'],
'causes' => $gateDecision['causes'],
];
}
@@ -1212,42 +1288,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
$scopeMatches = true;
$scopeReason = null;
$scopeCause = null;
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
$scopeMatches = false;
$scopeReason = 'Action department scope does not match the simulated lane.';
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
$scopeMatches = false;
$scopeReason = 'Action lane scope does not match the simulated lane.';
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
$scopeMatches = false;
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
$scopeMatches = false;
$scopeReason = 'Action machine type scope does not match the simulated lane.';
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
}
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
$enabled = (bool)($action['enabled'] ?? true);
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
$label = (string)$action['name'];
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
$causes = [];
$reason = 'Action would run for this simulator event.';
if (!$enabled) {
$reason = 'Action is disabled.';
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
} elseif (!$modeMatches) {
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
} elseif (!$scopeMatches) {
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
if ($scopeCause !== null) {
$causes[] = $scopeCause;
}
} elseif (!$conditionSatisfied) {
$reason = 'Action condition gate did not pass.';
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
}
$items[] = [
'kind' => 'action',
'id' => $actionId,
'node_id' => 'action:' . $actionId,
'label' => (string)$action['name'],
'node_ids' => ['action:' . $actionId],
'label' => $label,
'active' => $active,
'state' => $active ? 'active' : 'skipped',
'reason' => $reason,
'causes' => $causes,
'event' => (string)$action['event'],
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
'wash_mode' => (string)$action['wash_mode'],
@@ -1257,7 +1350,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
'relay_state' => $action['relay_state'],
'condition_id' => $conditionId,
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
'condition' => $conditionLabel,
'condition_satisfied' => $conditionSatisfied,
'scope' => [
'department' => (int)$action['department'],
@@ -1758,7 +1851,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
string $predictedStatus,
?string $reason
): array {
$id = 'signal:' . $sequence;
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
$targetBinding = $binding['node_id'] ?? null;
$nodeIds = [$id];
if (isset($payload['action_id'])) {
$nodeIds[] = 'action:' . (int)$payload['action_id'];
}
if (is_string($targetBinding) && $targetBinding !== '') {
$nodeIds[] = $targetBinding;
}
if ($relayId !== null && trim($relayId) !== '') {
$nodeIds[] = 'relay:' . $relayId;
}
$causes = $reason === null ? [] : [
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
];
return [
'kind' => 'signal',
'id' => $id,
'node_id' => $id,
'node_ids' => array_values(array_unique($nodeIds)),
'label' => $label,
'state' => $predictedStatus,
'sequence' => $sequence,
'runtime_stage' => $runtimeStage,
'signal_type' => $signalType,
@@ -1779,6 +1895,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'predicted_status' => $predictedStatus,
'skip_block_reason' => $reason,
'reason' => $reason,
'causes' => $causes,
];
}
@@ -2085,6 +2202,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
};
}
protected function debugRuleExpectedValue(string $type): mixed
{
return match (strtoupper(trim($type))) {
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
'IS_FALSE' => false,
'IS_SET' => 'set',
'IS_TRUE_OR_NOT_SET' => 'true or missing',
'IS_FALSE_OR_NOT_SET' => 'false or missing',
default => strtolower(str_replace('_', ' ', trim($type))),
};
}
protected function debugExpectedLabel(mixed $expected): string
{
return $this->debugValueLabel($expected);
}
protected function debugValueLabel(mixed $value): string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if ($value === null) {
return 'missing';
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'array' : $encoded;
}
if ($value instanceof \Stringable) {
return (string)$value;
}
return trim((string)$value) === '' ? 'empty' : (string)$value;
}
/**
* @return array<string,mixed>
*/
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
{
return [
'kind' => $kind,
'id' => $id,
'label' => $label,
'expected' => $expected,
'actual' => $actual,
'expected_label' => $this->debugExpectedLabel($expected),
'actual_label' => $this->debugValueLabel($actual),
'reason' => $reason,
];
}
protected function debugExpressionTraceReason(mixed $trace): ?string
{
if (!is_array($trace)) {
return null;
}
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed !== null) {
return (string)($failed['reason'] ?? 'Predicate did not pass.');
}
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
}
/**
* @param array<string,mixed> $trace
* @param array<string,mixed> $lookups
* @return array<string,mixed>|null
*/
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
{
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed === null) {
if (($expression['result'] ?? true) === false) {
return $this->debugCause(
'expression',
$trace['condition_id'] ?? null,
'Condition expression',
true,
false,
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
);
}
return null;
}
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
$subjectId = (int)($failed['subject_id'] ?? 0);
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
$actual = $failed['actual_value'] ?? null;
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
}
/**
* @param array<int,bool|null> $conditionResults
* @param array<int,bool|null> $visibleAnswers
* @return array{reason:string,causes:array<int,array<string,mixed>>}
*/
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
{
$gateType = strtoupper(trim($gateType));
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
'causes' => [
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
return [
'reason' => $active
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
'causes' => $active ? [] : [
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
],
];
}
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
'causes' => [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
return [
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
],
];
}
/**
* @param array<int,array<string,mixed>> $tasks
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDynamicImageButtons(array $tasks): array
{
$items = [];
foreach ($tasks as $task) {
$taskId = (int)($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
$active = (bool)($task['active'] ?? false);
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
$id = $taskId . ':' . (int)$index;
$nodeId = 'dynamic_image_button:' . $id;
$reason = $active
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
$items[] = [
'kind' => 'dynamic_image_button',
'id' => $id,
'node_id' => $nodeId,
'node_ids' => ['task:' . $taskId, $nodeId],
'label' => $buttonLabel,
'task_id' => $taskId,
'task_label' => $taskLabel,
'button_index' => (int)$index,
'button' => $button,
'state' => $active ? 'active' : 'hidden',
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
],
];
}
}
return $items;
}
protected function taskUsesProgramPicker(array $task): bool
{
if (in_array(
'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true
)) {
return true;
}
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
}
protected function isProgramNumberButton(mixed $button): bool
{
return is_int($button) && $button >= 0 && $button <= 11;
}
protected function dynamicImageButtonSequenceForTask(array $task): array
{
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
if (!$this->taskUsesProgramPicker($task)) {
return $buttons;
}
$sequence = ['program_picker'];
foreach ($buttons as $button) {
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
continue;
}
$sequence[] = $button;
}
return $this->normalizeButtonList($sequence);
}
/**
* @param array<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $actions
* @param array<int,array<string,mixed>> $signals
* @param array<int,array<string,mixed>> $dynamicImageButtons
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
{
$decisions = [];
foreach ([
'question' => $questions,
'condition' => $conditions,
'rule' => $rules,
'task' => $tasks,
'action' => $actions,
'signal' => $signals,
'dynamic_image_button' => $dynamicImageButtons,
] as $kind => $items) {
foreach ($items as $item) {
if (is_array($item)) {
$decisions[] = $this->debugDecisionFromItem($kind, $item);
}
}
}
return $decisions;
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
protected function debugDecisionFromItem(string $kind, array $item): array
{
$nodeIds = [];
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
if (is_string($nodeId) && $nodeId !== '') {
$nodeIds[] = $nodeId;
}
}
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
$nodeIds[] = $item['node_id'];
}
$state = (string)($item['state'] ?? '');
if ($state === '') {
$state = match ($kind) {
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
default => 'unknown',
};
}
$causes = [];
foreach ((array)($item['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
return [
'kind' => (string)($item['kind'] ?? $kind),
'id' => $item['id'] ?? ($item['node_id'] ?? null),
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
'state' => $state,
'reason' => (string)($item['reason'] ?? ''),
'node_ids' => array_values(array_unique($nodeIds)),
'causes' => $causes,
];
}
protected function debugDynamicImageButtonLabel(mixed $button): string
{
if (is_array($button)) {
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
return (string)$button[$key];
}
}
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'Button' : $encoded;
}
$label = trim((string)$button);
if (strtolower($label) === 'program_picker') {
return 'Program picker';
}
return $label === '' ? 'Button' : $label;
}
/**
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
@@ -2213,6 +2657,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $failed;
}
}
foreach (['when', 'then', 'default'] as $field) {
if (!is_array($trace[$field] ?? null)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
if ($failed !== null) {
return $failed;
}
}
foreach (['branches', 'cases'] as $field) {
foreach ((array)($trace[$field] ?? []) as $child) {
if (!is_array($child)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$child);
if ($failed !== null) {
return $failed;
}
}
}
return null;
}
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
enum selfserve_lane_services
{
case MACHINE; // Relay that controls the machine power
case PROGRAM_PICKER; // Relay that controls the machine program picker
}
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
Notes:
- Route parameter name is `condition_id`. That value is used as the task gate id.
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
- If an active task does not expose `MACHINE`, the relay will not be enabled.
Typical failures:
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
@@ -382,6 +382,12 @@ STOP flow details:
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
Customer start-wash release checklist:
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
Typical failures:
- `400` invalid parameters
@@ -480,7 +486,7 @@ Important enums:
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
- `selfserve_lane_services`: currently only `MACHINE`
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
### Machine Type And Wash Session Objects
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
{
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
redis->delete($this->getLaneCacheKey($laneId, $property));
return $this;
}
}
}
@@ -464,6 +464,7 @@ trait selfserve_lane_command_t
* @param selfserve_lane_command_arguments $arguments The arguments for the command
* @return selfserve_lane|selfserve_lane_command_t
* @throws Exception If the command cannot be executed
* @throws \Throwable
*/
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
{
@@ -517,6 +518,7 @@ trait selfserve_lane_command_t
// Open the entrance port before marking the lane occupied. Gateway timeouts are
// ambiguous because the relay may already have received the pulse.
$this->openEntrancePortForWashStart();
$this->turnOnCleanerRelayForWashStart();
} catch (\Throwable $e) {
$this->setCustomerNumber($previous_customer_number);
$this->setLicensePlate($previous_license_plate);
@@ -567,7 +569,7 @@ trait selfserve_lane_command_t
// Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Invoice the customer
$this->invoice();
$this->invoice($arguments);
// Only bill the machine wash product when the physical machine start signal was recorded.
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
// Finalize any active self-serve wash session for this lane
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/attachments/helpers/attachment_content.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
require_once WD . '/objects/subusers_o.php';
use classes\selfserve;
use classes\economic;
use Exception;
use attachments\helpers\attachment_content;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_status;
use objects\customer_vehicles_o;
use objects\order_items_o;
use objects\orders_o;
use objects\selfserve_wash_sessions_o;
use objects\subusers_o;
trait selfserve_lane_invoice_t
{
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
* @return bool True on success, false on failure
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
*/
public function invoice(): bool
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
{
$this->last_invoice_order_id = null;
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
$included_minutes = $this->resolveIncludedMinutesForBilling();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
return true;
if ($billable_minutes > 0) {
$order = $this->createInvoiceOrderContext($arguments);
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
}
$order = $this->createInvoiceOrderContext();
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -212,10 +219,13 @@ trait selfserve_lane_invoice_t
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(): orders_o
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
{
$billing_customer_number = $this->getCustomerNumber();
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
$order = (new orders_o())->add(
$this->getCustomerNumber(),
$order_customer_number,
self::INVOICE_SYSTEM_USER_ID,
'',
'',
@@ -224,10 +234,101 @@ trait selfserve_lane_invoice_t
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
return $order;
}
protected function attachSelfServeMetadataToOrder(
orders_o $order,
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): void {
try {
$order->addAttachment(
(new attachment_content())->setOther(
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
)
);
} catch (\Throwable) {
// Metadata attachments must not block billing; the order itself is the source of record.
}
}
protected function buildSelfServeOrderAttachmentPayload(
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): array {
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
$subuser_id = $arguments?->subuser_id;
return [
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
'source' => 'selfserve',
'customer_number' => $billing_customer_number,
'draft_customer_number' => $draft_customer_number,
'subuser_id' => $subuser_id,
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
'session_id' => $session?->id,
'lane_id' => (int)$this->id,
'department_id' => (int)$this->department_lane->department->value(),
'license_plate' => (string)$this->getLicensePlate(),
'lane_status' => $this->getLaneStatus()->name,
'lane_mode' => $this->getLaneMode()->name,
'wash_start_time' => (int)$this->getWashStartTime(),
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
'created_at' => date('Y-m-d H:i:s'),
];
}
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
{
$license_plate = trim((string)$this->getLicensePlate());
if ($license_plate === '') {
return null;
}
try {
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
(int)$this->id,
selfserve::standardize_registration($license_plate),
$billing_customer_number > 0 ? $billing_customer_number : null
);
return $session->exists() ? $session : null;
} catch (\Throwable) {
return null;
}
}
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
{
if ($subuser_id === null || $subuser_id <= 0) {
return null;
}
try {
$subuser = (new subusers_o())->select($subuser_id);
if (!$subuser->exists()) {
return [
'id' => $subuser_id,
];
}
return [
'id' => (int)$subuser->id,
'name' => $subuser->name->value(),
'username' => $subuser->username->value(),
'email' => $subuser->email->value(),
];
} catch (\Throwable) {
return [
'id' => $subuser_id,
];
}
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
{
return (new order_items_o())->addItemToOrder(
@@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t
*/
public function setMachineRelayStatusHard(bool $on): bool
{
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
}
/**
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
return $this;
}
}
public function setLaneStatusAudit(?array $audit): self
{
if ($audit === null) {
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
return $this;
}
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
]);
return $this;
}
public function getLaneStatusAudit(): ?array
{
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
if (!is_array($audit)) {
return null;
}
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
? (int)$audit['modified_by_user_id']
: null;
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
return null;
}
return [
'modified_at' => $modified_at !== '' ? $modified_at : null,
'modified_by_user_id' => $modified_by_user_id,
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
];
}
}
@@ -130,6 +130,10 @@ class department_lanes_o extends db
public function asArray(): array
{
$status = (string)$this->getLaneStatus()->name;
$machine_status_audit = $this->getMachineStatusAudit();
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
return [
'id' => (int)$this->id,
'department' => (int)$this->department->value(),
@@ -143,13 +147,76 @@ class department_lanes_o extends db
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
'selfserve_enabled' => $this->isSelfServeEnabled(),
// Status of the lane
'status' => (string)$this->getLaneStatus()->name,
'status' => $status,
'machine_status_enabled' => self::isOperationalStatusName($status),
'machine_status_audit' => $machine_status_audit,
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
'selfserve_configured' => $selfserve_configuration_warnings === [],
'dognvask_configured' => $selfserve_configuration_warnings === [],
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
// Timestamps
'created_at' => (string)$this->created_at->value(),
'updated_at' => (string)$this->updated_at->value(),
];
}
private function getMachineStatusAudit(): ?array
{
try {
$lane = (new selfserve())->lane((int)$this->id);
if (!method_exists($lane, 'getLaneStatusAudit')) {
return null;
}
$audit = $lane->getLaneStatusAudit();
return is_array($audit) ? $audit : null;
} catch (\Throwable) {
return null;
}
}
public function getSelfServeConfigurationWarnings(): array
{
self::requireSelected();
$required_fields = [
'relay_in_id' => 'Indgangsrelæ',
'relay_out_id' => 'Udgangsrelæ',
'relay_machine_id' => 'Maskinrelæ',
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
'relay_machine_cleaner_id' => 'Vaskerelæ',
'dynamic_image_id' => 'Maskinstatusbillede',
'machine_type_id' => 'Maskintype',
];
$warnings = [];
foreach ($required_fields as $field => $label) {
if ($this->hasConfiguredFieldValue($field)) {
continue;
}
$warnings[] = [
'field' => $field,
'label' => $label,
'message' => $label . ' mangler',
];
}
return $warnings;
}
public function isSelfServeConfigured(): bool
{
return $this->getSelfServeConfigurationWarnings() === [];
}
public static function isOperationalStatusName(string $status): bool
{
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
}
public function isSelfServeEnabled(): bool
{
self::requireSelected();
@@ -184,6 +251,29 @@ class department_lanes_o extends db
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function hasConfiguredFieldValue(string $field): bool
{
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
return false;
}
$value = $this->{$field}->value();
if ($value === null) {
return false;
}
if (is_string($value)) {
$value = trim($value);
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
}
if (is_numeric($value)) {
return (int)$value > 0;
}
return (bool)$value;
}
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
{
if ($lane_id <= 0) {
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
if (is_string($btn)) {
$trimmed = trim($btn);
$specialButton = strtolower($trimmed);
if ($specialButton === 'reset' || $specialButton === 'start') {
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
$ids[] = $specialButton;
continue;
}
+26 -1
View File
@@ -11,6 +11,9 @@ class products_o extends db
{
use db_object_t;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
/**
* The name of the product
* @var object_property
@@ -214,7 +217,7 @@ class products_o extends db
'piktogram' => $this->piktogram->value(),
'economic_product_id' => $this->economic_product_id->value(),
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
'requires_note' => (bool)$this->requires_note->value(),
'requires_note' => $this->requiresOrderItemNote(),
'is_wash' => (bool)$this->is_wash->value(),
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
@@ -224,6 +227,28 @@ class products_o extends db
];
}
public static function productDataRequiresOrderItemNote(array $product): bool
{
if ((bool)($product['requires_note'] ?? false)) {
return true;
}
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
return true;
}
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
}
public function requiresOrderItemNote(): bool
{
return self::productDataRequiresOrderItemNote([
'id' => $this->id,
'name' => (string)$this->name->value(),
'requires_note' => (bool)$this->requires_note->value(),
]);
}
/**
* Apply department pricing to a list of products
* @param array $products
+42 -12
View File
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
public object_property $updated_at;
public object_property $deleted_at;
const defaultPermissions = [
subusers_permission_node_key::VEHICLES_LIST,
subusers_permission_node_key::SELFSERVE_ADD,
subusers_permission_node_key::BOOKINGS_LIST,
subusers_permission_node_key::BOOKINGS_ADD,
subusers_permission_node_key::BOOKINGS_EDIT,
subusers_permission_node_key::BOOKINGS_DELETE,
'VEHICLES_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
];
private static function normalizePermissionsValue(mixed $raw): array
public static function normalizePermissionsValue(mixed $raw): array
{
if ($raw === null || $raw === '') {
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
return [];
}
if ($raw instanceof subusers_permission_node_key) {
return [$raw->name];
}
if (is_array($raw)) {
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
$permissions = [];
$permissionCandidates = array_is_list($raw)
? $raw
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
foreach ($permissionCandidates as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
if (json_last_error() === JSON_ERROR_NONE) {
return self::normalizePermissionsValue($decoded);
}
$permission = strtoupper(trim($raw));
if (subusers_permission_node_key::tryFrom($permission) !== null) {
return [$permission];
}
}
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
{
global $db;
$permissions = self::normalizePermissionsValue($permissions);
$tmp = $this->add_object([
'billing_customer_number' => (int)$billing_customer_number,
'subuser' => (int)$subuser,
'enabled' => (bool)$enabled,
'note' => !empty($note) ? $db->escape_string($note) : null,
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$this->id = (int)$tmp;
$this->getObjectProperties();
+26 -5
View File
@@ -15,6 +15,11 @@ class subusers_o extends db
{
use db_object_t;
public const PASSWORD_MIN_LENGTH = 8;
public const PASSWORD_MAX_LENGTH = 255;
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
public object_property $username;
public object_property $password;
public object_property $name;
@@ -62,6 +67,23 @@ class subusers_o extends db
return (bool)$this->two_factor_enabled->value();
}
/**
* @throws Exception
*/
public static function assertValidPassword(string $password): void
{
if (
strlen($password) < self::PASSWORD_MIN_LENGTH
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|| !preg_match(self::PASSWORD_PATTERN, $password)
) {
throw new Exception(
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
);
}
}
/**
* @throws Exception
*/
@@ -103,11 +125,9 @@ class subusers_o extends db
{
global $db, $response;
try {
if (!empty($password)) {
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
}
$passwordWasProvided = !empty($password);
if ($passwordWasProvided) {
self::assertValidPassword($password);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
}
@@ -163,6 +183,7 @@ class subusers_o extends db
public function setPassword(string $password): self
{
self::requireSelected();
self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this;
}
+374 -9
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding
description: Branding options management
- name: Roles
@@ -4071,13 +4073,15 @@ paths:
- name: buttons
in: query
required: false
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
schema:
oneOf:
- type: string
- type: array
items:
type: integer
oneOf:
- type: integer
- type: string
- name: current_step
in: query
required: false
@@ -5456,7 +5460,7 @@ paths:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
@@ -5613,6 +5617,29 @@ paths:
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/path-confirmations:
post:
tags:
- Self-Serve
summary: Confirm or reset a projected self-serve studio path
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
operationId: confirmSelfserveStudioPath
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
responses:
'200':
description: Path confirmation updated
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/publish:
post:
tags:
@@ -6186,7 +6213,7 @@ paths:
reference: {type: string}
po: {type: string}
pickup: {type: boolean}
order_id: {type: integer}
order_id: {type: integer, nullable: true}
items:
type: array
items:
@@ -8043,7 +8070,15 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema: {}
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
/worker/debug:
get:
@@ -9295,7 +9330,10 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -9353,6 +9391,9 @@ paths:
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
derived from the tasks currently shown to the user after answering the self-serve questions.
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
confirming a wash start.
operationId: setSelfServeLaneAllowedServices
requestBody:
required: true
@@ -9675,7 +9716,9 @@ paths:
description: |
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
enabled; an explicit call to this endpoint is required.
enabled; an explicit call to this endpoint is required. Operator callers require
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
operationId: enableSelfServeLaneMachineRelay
requestBody:
required: true
@@ -12830,6 +12873,189 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/superuser/releases/operations:
get:
tags:
- Release Manager
summary: List release operation runs
operationId: listReleaseOperations
parameters:
- in: query
name: channel_id
schema:
type: integer
- in: query
name: operation_type
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
responses:
'200':
description: Release operation runs
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/releases/operations/{id}:
get:
tags:
- Release Manager
summary: Get release operation details
operationId: getReleaseOperation
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Release operation details
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/releases/test-runs:
post:
tags:
- Release Manager
summary: Run Release Manager diagnostics
operationId: runReleaseTest
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Release test operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/channels/{id}/sync:
post:
tags:
- Release Manager
summary: Sync latest branch commits into a release channel
operationId: syncReleaseChannel
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'202':
description: Channel sync operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/issues/actions:
post:
tags:
- Release Manager
summary: Run a Release Manager issue action
operationId: runReleaseIssueAction
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issue_key:
type: string
action_id:
type: string
inputs:
type: object
additionalProperties: true
confirm:
type: boolean
additionalProperties: true
responses:
'200':
description: Issue action result
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
BearerAuth:
@@ -17695,10 +17921,10 @@ components:
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder]
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
entity:
type: string
enum: [question, condition, task]
enum: [question, condition, task, action, path]
description: Standalone rule operations are not accepted for schema_version 2 drafts.
id:
type: integer
@@ -17958,6 +18184,45 @@ components:
items:
type: object
additionalProperties: true
actions:
type: array
items:
type: object
additionalProperties: true
dynamic_image_buttons:
type: array
items:
type: object
additionalProperties: true
decisions:
type: array
items:
type: object
required: [kind, id, label, state, reason, node_ids, causes]
properties:
kind:
type: string
id:
oneOf:
- type: integer
- type: string
nullable: true
label:
type: string
state:
type: string
reason:
type: string
node_ids:
type: array
items:
type: string
causes:
type: array
items:
type: object
additionalProperties: true
additionalProperties: true
hardware:
type: object
additionalProperties: true
@@ -18046,6 +18311,8 @@ components:
items: { type: integer }
max_states: { type: integer }
path_sample_count: { type: integer }
confirmations:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
outcomes:
type: array
items:
@@ -18061,6 +18328,15 @@ components:
type: boolean
progress:
$ref: '#/components/schemas/SelfserveStudioPathProgress'
confirmations:
type: object
properties:
summary:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
removed:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
SelfserveStudioPathProgress:
type: object
@@ -18146,6 +18422,95 @@ components:
node_ids:
type: array
items: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathConfirmationSummary:
type: object
properties:
confirmed: { type: integer }
unconfirmed: { type: integer }
stale: { type: integer }
removed: { type: integer }
total: { type: integer }
SelfserveStudioPathConfirmationRequest:
type: object
required: [department, path_signature]
properties:
department: { type: integer }
action:
type: string
enum: [confirm, reset, delete, clear]
default: confirm
path_signature: { type: string }
result_signature:
type: string
description: Required when action is confirm.
scope:
type: object
additionalProperties: true
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
SelfserveStudioPathConfirmation:
type: object
properties:
id:
type: integer
nullable: true
department_id: { type: integer }
lane_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
config_version_id:
type: integer
nullable: true
config_source: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
scope:
type: object
additionalProperties: true
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathTask:
type: object
@@ -18,6 +18,35 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
global $response;
$this->requirePermission('list_department_lanes');
self::requireParameters(['department_id']);
$department_id = (int)self::getParameter('department_id');
self::requireType($department_id, self::type_int());
self::requireMinValue($department_id, 1);
self::requireDepartmentAccess($department_id);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
$response->error('Invalid session', 400);
}
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
$response->success(
array_map(
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
(new department_lanes_o())->getDepartmentLanes($department_id)
)
);
},
[
'list_department_lanes' => 'List department lane status toggles'
]
);
$this->get('/department/lanes', function () {
// Require the user to be logged in
@@ -135,7 +164,7 @@ class departmentLanesRoute
* Query parameters:
* - department (int, required)
* - lane (int, required)
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
* - current_step (int >= 0, optional) → current click/step indicator
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
$service = new selfserve_studio_graph();
$result = in_array($action, ['delete', 'reset', 'clear'], true)
? $service->resetPathConfirmation($departmentId, $payload)
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
$response->success($result);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
@@ -30,6 +30,8 @@ class moduleSelfServeRoute
{
use route_t;
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
public function run(): void
{
global /** @var response $response */
@@ -60,6 +62,60 @@ class moduleSelfServeRoute
]
);
$this->put('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_set');
self::requireParameters(['lane_id', 'enabled']);
$lane_id = (int)self::getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$department_lane = (new department_lanes_o())->select($lane_id);
if (!$department_lane->exists()) {
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
$enabled = $this->requestedBoolean('enabled');
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
$lane = (new selfserve())->lane($lane_id);
$lane->setLaneStatus($target_status);
$user = (new authentication())->get_user();
$machine_status_audit = [
'modified_at' => date(DATE_ATOM),
'modified_by_user_id' => $user ? (int)$user->id : null,
'modified_by_name' => $this->machineStatusAuditUserName($user),
];
$lane->setLaneStatusAudit($machine_status_audit);
(new logs_o())->add(
'selfserve',
'global',
1,
$user ? $user->id : 0,
'SET_LANE_MACHINE_STATUS',
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
);
$status = (string)$lane->getLaneStatus()->name;
$response->success([
'id' => $lane->id,
'status' => $status,
'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
'machine_status_audit' => $machine_status_audit,
'machine_status_modified_at' => $machine_status_audit['modified_at'],
'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
'lane' => $department_lane->asArray(),
]);
},
[
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
]
);
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
global $response;
@@ -389,7 +445,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () {
global $response;
self::requirePermission('modules_selfserve_lane_command_execute');
$selfserve = new selfserve();
// Get the request user
$user = (new authentication())->get_user();
@@ -412,45 +467,75 @@ class moduleSelfServeRoute
if ($command === null) {
$response->error("Invalid command: " . $commandParam);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
// Require permissions for specific commands
switch ($command) {
case selfserve_lane_command::START:
self::requirePermission('modules_selfserve_lane_command_execute_start');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_start',
false
);
break;
case selfserve_lane_command::STOP:
self::requirePermission('modules_selfserve_lane_command_execute_stop');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_stop',
true,
true
);
break;
case selfserve_lane_command::RESERVE:
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_reserve',
false
);
break;
case selfserve_lane_command::RELEASE:
self::requirePermission('modules_selfserve_lane_command_execute_release');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_release',
false
);
break;
case selfserve_lane_command::RESET:
self::requirePermission('modules_selfserve_lane_command_execute_reset');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_reset',
false
);
break;
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
$this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_access_gate',
$lane,
(int)$user->customer_number->value()
$customer_number
);
break;
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
$this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_exit_gate',
$lane,
(int)$user->customer_number->value()
$customer_number
);
break;
}
// Execute the command
try {
$this->applyShellyTransportOverride($lane);
$subuser = (new authentication())->get_subuser();
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
'customer_number' => $customer_number, // Get customer number from request user
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
]);
$lane->execute($command, $args);
$response->success([
@@ -493,7 +578,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () {
global $response;
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
@@ -516,6 +600,8 @@ class moduleSelfServeRoute
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
// Build allowed services from provided tasks
$lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$allowed_services = [];
foreach ($task_ids as $tid) {
if ($tid <= 0) continue;
@@ -835,7 +921,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
@@ -848,6 +933,8 @@ class moduleSelfServeRoute
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
@@ -1271,9 +1358,87 @@ class moduleSelfServeRoute
$lane->setShellyTransportOverride($transport);
}
/**
* @param array<int,string> $permissions
*/
private function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (!$this->hasPermission($permission)) {
return false;
}
}
return true;
}
/**
* @param array<int,string> $elevated_permissions
*/
private function requireSelfServeLaneAccess(
selfserve_lane $lane,
int $customer_number,
array $elevated_permissions,
bool $requires_active_wash = false,
bool $requires_operational_lane = true
): void {
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
if ($requires_active_wash) {
$customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
&& (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
} else {
$customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
}
if ($customer_allowed) {
return;
}
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
}
private function requireSelfServeLaneCommandPermission(
selfserve_lane $lane,
int $customer_number,
string $command_permission,
bool $allow_customer_self_serve,
bool $requires_active_wash = false
): void {
$elevated_permissions = [
'modules_selfserve_lane_command_execute',
$command_permission,
];
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
if ($allow_customer_self_serve) {
$customer_allowed = $requires_active_wash
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
if ($customer_allowed) {
return;
}
}
$this->emitForbidden(
$allow_customer_self_serve
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
: $elevated_permissions
);
}
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
{
if (self::hasPermission($permission)) {
$elevated_permissions = [
'modules_selfserve_lane_command_execute',
$permission,
];
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
@@ -1281,21 +1446,70 @@ class moduleSelfServeRoute
return;
}
self::requirePermission($permission);
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
}
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
&& $this->isLaneSelfServeOperationallyEnabled($lane);
}
protected function canCustomerUseActiveSelfServeLane(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 session lookup below.
}
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
}
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0) {
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
}
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
{
try {
if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
return false;
}
} catch (\Throwable) {
return false;
}
$department_id = (int)$lane->department_lane?->department?->value();
$department_id = $this->departmentIdForLane($lane);
if ($department_id <= 0) {
return false;
}
return $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
try {
$department = (new departments_o())->select($department_id);
return $department->exists() && $department->getSelfServeEnabled();
} catch (\Throwable) {
return false;
}
}
protected function departmentIdForLane(selfserve_lane $lane): int
{
try {
return (int)$lane->department_lane?->department?->value();
} catch (\Throwable) {
return 0;
}
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
@@ -1389,6 +1603,33 @@ class moduleSelfServeRoute
return $toggle_after;
}
private function machineStatusAuditUserName(?object $user): ?string
{
if (!$user) {
return null;
}
foreach (['display_name', 'email'] as $property) {
if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
continue;
}
$value = trim((string)$user->{$property}->value());
if ($value !== '' && strtolower($value) !== 'unnamed') {
return $value;
}
}
if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
$customer_number = (int)$user->customer_number->value();
if ($customer_number > 0) {
return 'Kunde ' . $customer_number;
}
}
return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
}
private function requestedBoolean(string $parameter, bool $default = false): bool
{
if (!self::isParametersSet([$parameter])) {
+6 -14
View File
@@ -4,28 +4,20 @@ namespace routes;
use traits\route_t;
require_once dirname(__DIR__) . '/classes/cors_policy.php';
class optionsRoute
{
use route_t;
public function run(): void
{
// When the OPTIONS method is requested, accept all using regex
$this->options('/.*', function () {
global $CORS;
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *');
header('Content-Type: application/json');
http_response_code(200);
} else {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'CORS origin not allowed']);
}
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
\classes\cors_policy::emitHeaders($preflight['headers']);
http_response_code($preflight['status']);
echo $preflight['body'];
});
}
}
@@ -12,6 +12,7 @@ use objects\departments_o;
use objects\logs_o;
use objects\order_bookings_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;
@@ -265,6 +266,7 @@ class orderBookingRoute
$po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects
$order_id_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */
$auth = new authentication();
@@ -293,6 +295,7 @@ class orderBookingRoute
/**
* Update the object
*/
$previous_order_id = (int)($object->order_id->value() ?? 0);
$data = [
...(self::hasPermission($permission_other) && !empty($customer_number) ? [
'customer_number' => (int)$customer_number->customer_number->value(),
@@ -307,9 +310,12 @@ class orderBookingRoute
...(isset($po) ? ['po' => $po] : []),
...(isset($pickup) ? ['pickup' => $pickup] : []),
...(isset($items) ? ['items' => $items] : []),
...(isset($order_id) ? ['order_id' => $order_id] : []),
...($order_id_was_set ? ['order_id' => $order_id] : []),
];
$object->update($data);
if ($order_id_was_set && $order_id === null) {
self::clearMatchingOrderBookingLink($object, $previous_order_id);
}
/**
* Return the object
*/
@@ -529,7 +535,7 @@ class orderBookingRoute
return $value;
}
private function getTargetOrderId(bool $required = true): int|string|null
private function getTargetOrderId(bool $required = true): int|null
{
global $response;
$parameter = 'order_id';
@@ -541,7 +547,7 @@ class orderBookingRoute
} else {
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
// Check if the value is null
if ($this->getParameter($parameter) === null) return "null";
if ($this->getParameter($parameter) === null) return null;
}
self::requireMinLength($parameter, 1);
self::requireMaxLength($parameter, 9);
@@ -551,6 +557,28 @@ class orderBookingRoute
return $value;
}
/**
* @throws Exception
*/
private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void
{
if ($previous_order_id <= 0) {
return;
}
$previous_order = (new orders_o())->select($previous_order_id);
if (!$previous_order->exists()) {
return;
}
if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) {
return;
}
$previous_order->booking_id->set(null);
$previous_order->objectChanged();
}
private function getTargetCustomer(bool $required = true): users_o|null
{
global $response;
@@ -6,6 +6,7 @@ use classes\authentication;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use traits\route_t;
class orderItemsRoute
@@ -69,6 +70,13 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -203,6 +211,14 @@ class orderItemsRoute
if (!isset($data['quantity'])) {
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
// Update the order item
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
// Log the incident
+3
View File
@@ -2,6 +2,7 @@
namespace routes;
use classes\release_manager;
use traits\route_t;
class pingRoute
@@ -15,6 +16,8 @@ class pingRoute
$response->success([
'message' => 'pong',
'time' => date('c'),
'backend_version' => release_manager::backendVersion(),
'api_commit_sha' => release_manager::backendCommitSha(),
]);
});
}
+2 -2
View File
@@ -152,7 +152,7 @@ class productsRoute
'piktogram' => (string)$product['piktogram'],
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (boolean)$product['apply_category_discount'],
'requires_note' => (boolean)$product['requires_note'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => (new product_options_o())->getProductOptions($product['id']),
@@ -432,4 +432,4 @@ class productsRoute
]
);
}
}
}
@@ -45,6 +45,21 @@ class releaseManagerRoute
}
});
$this->post('/release/gate/test-runs', function () {
global $response;
$manager = new release_manager();
if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) {
$response->error(['message' => 'Invalid release gate token.'], 401);
return;
}
try {
$response->success($manager->runReleaseTest($this->requestPayload(), null), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
});
$this->get('/superuser/releases', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
@@ -61,6 +76,34 @@ class releaseManagerRoute
'superuser_release_manager_view' => 'View Release Manager source configuration',
]);
$this->get('/superuser/releases/operations', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->listOperations($this->getParametersAsArray()));
}, [
'superuser_release_manager_view' => 'View release operation runs',
]);
$this->get('/superuser/releases/operations/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
try {
$response->success((new release_manager())->operationDetail($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_release_manager_view' => 'Inspect release operation diagnostics',
]);
$this->post('/superuser/releases/test-runs', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->runReleaseTest($this->requestPayload(), $this->actorUserId()), 202);
}, [
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
]);
$this->post('/superuser/releases/config', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
@@ -161,6 +204,18 @@ class releaseManagerRoute
'superuser_release_manager_rollback' => 'Rollback an active release channel',
]);
$this->post('/superuser/releases/channels/{id}/sync', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->syncChannel($this->routeId(), $this->actorUserId()), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Sync the latest frontend and API branch commits into a release channel',
]);
$this->post('/superuser/releases/channels/{id}/bundle', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
@@ -380,6 +435,14 @@ class releaseManagerRoute
'superuser_release_manager_deploy' => 'Promote a deployment to its release channel',
]);
$this->post('/superuser/releases/issues/actions', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->runIssueAction($this->requestPayload(), $this->actorUserId()));
}, [
'superuser_release_manager_deploy' => 'Run a safe Release Manager issue resolution action',
]);
$this->post('/superuser/releases/replay-targets', function () {
global $response;
$this->requirePermission('superuser_release_manager_replay');
@@ -451,4 +514,20 @@ class releaseManagerRoute
return $payload;
}
private function releaseGateToken(): string
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$authorization = (string)($headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches) === 1) {
return trim($matches[1]);
}
return trim((string)(
$headers['X-Release-Gate-Token']
?? $headers['x-release-gate-token']
?? $_SERVER['HTTP_X_RELEASE_GATE_TOKEN']
?? ''
));
}
}
+389 -89
View File
@@ -44,6 +44,14 @@ class subusersRoute
}
}
private function requireSubuserPasswordPolicy(string $password): void
{
self::requireType($password, self::type_string());
self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH);
self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH);
self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE);
}
private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int
{
global $response;
@@ -121,6 +129,36 @@ class subusersRoute
return $normalized === '' ? null : $normalized;
}
private function resolveCustomerNames(array $customerNumbers): array
{
$customerNumbers = array_values(array_unique(array_filter(
array_map('intval', $customerNumbers),
static fn (int $customerNumber): bool => $customerNumber > 0
)));
if ($customerNumbers === []) {
return [];
}
return (new users_o())->getCustomerNames($customerNumbers, false);
}
private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string
{
if ($customerNumber <= 0) {
return null;
}
$key = (string)$customerNumber;
$name = $customerNames[$key] ?? null;
if (!is_string($name)) {
$name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null;
}
$name = trim((string)$name);
return $name === '' || $name === 'Unknown Customer' ? null : $name;
}
private function assertSubuserIdentifiersAvailable(
?int $phoneCountryCode,
?int $phone,
@@ -244,10 +282,10 @@ class subusersRoute
];
}
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? $this->parsePermissionsPayload($grant->permissions->value(), []) : [];
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
@@ -274,6 +312,8 @@ class subusersRoute
'invite_accepted' => $inviteAccepted,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => $customerNumber,
'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber),
'grant_id' => $grant ? (int)$grant->id : null,
'grant_enabled' => $grantEnabled,
'grant_note' => $grant ? $grant->note->value() : null,
@@ -290,6 +330,10 @@ class subusersRoute
'enabled' => 1,
'deleted_at' => null,
], ['permissions', 'billing_customer_number']);
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
));
return [
'id' => (int)$subuser->id,
@@ -298,11 +342,12 @@ class subusersRoute
'email' => $subuser->email->value(),
'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
'grants' => array_map(function ($grant) {
'grants' => array_map(function ($grant) use ($customerNames) {
$customerNumber = (int)$grant['billing_customer_number'];
return [
'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']),
'billing_customer_number' => (int)$grant['billing_customer_number'],
'permissions' => $this->parsePermissionsPayload($grant['permissions'] ?? null, []),
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
'billing_customer_number' => $customerNumber,
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
];
}, $grants),
'created_at' => $subuser->created_at->value() ?? null,
@@ -312,6 +357,297 @@ class subusersRoute
];
}
private function parseSuperuserPaginationRequest(): array
{
global $response;
$page = max(1, (int)($response->getRequestParameter('page') ?: 1));
$limitRaw = $response->getRequestParameter('limit');
$limit = is_string($limitRaw) && strtolower($limitRaw) === 'all'
? 1000
: (int)($limitRaw ?: 100);
$limit = max(1, min($limit, 1000));
$search = $this->normalizeOptionalString($response->getRequestParameter('search'));
$orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC');
$orderParts = explode(':', $orderRaw, 2);
$orderField = $orderParts[0] ?? 'created_at';
$orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$allowedOrderFields = [
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'name' => 's.`name`',
];
if (!isset($allowedOrderFields[$orderField])) {
$orderField = 'created_at';
}
return [
'page' => $page,
'limit' => $limit,
'search' => $search,
'order_field' => $orderField,
'order_sql' => $allowedOrderFields[$orderField],
'order_direction' => $orderDirection,
];
}
private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void
{
if ($params === []) {
return;
}
$refs = [];
foreach ($params as $key => $value) {
$refs[$key] = &$params[$key];
}
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserSubuserManagementPayload(array $row): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$accessState = 'inactive';
if (!empty($row['grant_id']) && $grantEnabled) {
$accessState = $setupRequired ? 'pending_setup' : 'active';
} elseif (!empty($row['grant_id'])) {
$accessState = 'disabled';
}
return [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
'name' => $row['name'] ?? null,
'email' => $row['email'] ?? null,
'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null,
'phone' => $row['phone'] !== null ? (int)$row['phone'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null,
'suspended_at' => $row['suspended_at'] ?? null,
'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)),
'setup_required' => $setupRequired,
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_id' => (int)$row['grant_id'],
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function listSuperuserSubusers(): array
{
global $db, $response;
$pagination = $this->parseSuperuserPaginationRequest();
$offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit'];
$where = ['g.`deleted_at` IS NULL'];
$params = [];
$types = '';
$includeNonEnabled = true;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeNonEnabled = $tmp === null ? true : (bool)$tmp;
}
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($pagination['search'] !== null) {
$where[] = "(
CAST(s.`id` AS CHAR) LIKE ?
OR s.`username` LIKE ?
OR s.`name` LIKE ?
OR s.`email` LIKE ?
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
OR CAST(s.`phone` AS CHAR) LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR u.`display_name` LIKE ?
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
$params[] = $search;
$types .= 's';
}
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
}
$this->bindStatementParameters($countStatement, $types, $params);
$countStatement->execute();
$countResult = $countStatement->get_result();
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$dataSql = "
SELECT
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`,
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
u.`display_name` AS `customer_name`
$fromSql
$whereSql
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$dataStatement = $db->conn->prepare($dataSql);
if ($dataStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$dataParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
$dataStatement->execute();
$result = $dataStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$dataStatement->close();
$response->paginate(
(int)$pagination['page'],
(int)$pagination['limit'],
$total,
$pagination['search'],
null,
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
{
global $response;
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
self::requireParameters(['name', 'phone_country_code', 'phone']);
$name = $this->normalizeOptionalString(self::getParameter('name'));
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$enabled = $tmp === null ? true : (bool)$tmp;
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
}
self::requireType($phoneCountryCode, self::type_int());
self::requireType($phone, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser === null) {
try {
$subuser = (new subusers_o())->add(
null,
null,
$name,
null,
$phoneCountryCode,
$phone
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
if ($grant === null) {
try {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
$enabled,
$note,
$permissions ?? subuser_grants_o::defaultPermissions
);
} catch (Exception $exception) {
$response->error('Failed to create subuser grant', 500);
}
} else {
$grantUpdates = ['enabled' => $enabled];
if (self::isParametersSet(['note'])) {
$grantUpdates['note'] = $note;
}
if ($permissions !== null) {
$grantUpdates['permissions'] = $permissions;
}
try {
$grant->update($grantUpdates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$grant = (new subuser_grants_o())->select((int)$grant->id);
$grant->getObjectProperties();
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'invite' => $invite,
]);
}
public function run(): void
{
// =============================
@@ -387,7 +723,7 @@ class subusersRoute
'name' => $subuser->name->value(),
'enabled' => $o->enabled,
'note' => $o->note,
'permissions' => json_decode($o->permissions, true) ?: [],
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
'created_at' => $o->created_at,
'updated_at' => $o->updated_at,
];
@@ -649,10 +985,7 @@ class subusersRoute
$token = (string)self::getParameter('token');
$password = (string)self::getParameter('password');
$name = (string)self::getParameter('name');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
$this->requireSubuserPasswordPolicy($password);
self::requireType($name, self::type_string());
self::requireMinLength('name', 3);
self::requireMaxLength('name', 255);
@@ -747,9 +1080,7 @@ class subusersRoute
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
$this->requireSubuserPasswordPolicy($password);
try {
if (password_verify($password, $subuser->password->value())) {
if ($subuser->isTwoFactorEnabled()) {
@@ -770,9 +1101,18 @@ class subusersRoute
// =============================
// Subusers - List & Get (with grant visibility)
// =============================
$this->get('/superuser/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
$response->success($this->listSuperuserSubusers());
}, [
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->get('/subusers', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
$customerName = $this->resolveCustomerName($customerNumber);
$includeNonEnabled = false;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
@@ -786,13 +1126,13 @@ class subusersRoute
);
$objects = (new subusers_o())
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) {
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber, $customerName) {
$subuser = (new subusers_o())->select((int)$o['id']);
if (!$subuser->exists()) {
return null;
}
$subuser->getObjectProperties();
return $this->buildSubuserManagementPayload($subuser, $customerNumber);
return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName);
}, null, [], $existsClause);
if (is_array($objects)) {
@@ -873,92 +1213,52 @@ class subusersRoute
$response->success($this->buildCurrentSubuserPayload($subuser));
}, []);
$this->post('/superuser/subusers/invite', function () {
self::requireParameters(['customer_number']);
$this->requirePermission('add_subusers');
$customerNumber = (int)self::getParameter('customer_number');
self::requireType($customerNumber, self::type_int());
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/subusers/invite', function () {
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
]);
$this->post('/superuser/subusers/invite/resend', function () {
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
self::requireParameters(['name', 'phone_country_code', 'phone']);
$this->requirePermission('edit_subusers');
self::requireParameters(['id']);
$subuserId = (int)self::getParameter('id');
self::requireType($subuserId, self::type_int());
$name = $this->normalizeOptionalString(self::getParameter('name'));
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$enabled = $tmp === null ? true : (bool)$tmp;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
$subuser->getObjectProperties();
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
}
self::requireType($phoneCountryCode, self::type_int());
self::requireType($phone, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser === null) {
try {
$subuser = (new subusers_o())->add(
null,
null,
$name,
null,
$phoneCountryCode,
$phone
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
if ($grant === null) {
try {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
$enabled,
$note,
$permissions ?? subuser_grants_o::defaultPermissions
);
} catch (Exception $exception) {
$response->error('Failed to create subuser grant', 500);
}
} else {
$grantUpdates = ['enabled' => $enabled];
if (self::isParametersSet(['note'])) {
$grantUpdates['note'] = $note;
}
if ($permissions !== null) {
$grantUpdates['permissions'] = $permissions;
}
try {
$grant->update($grantUpdates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$grant = (new subuser_grants_o())->select((int)$grant->id);
$grant->getObjectProperties();
if (!$subuser->requiresSetup()) {
$response->error('Driver account already accepted the invitation.', 409);
}
$invite = $this->issueSetupInvite($subuser);
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $grant->asArray(),
'subuser' => [
'id' => (int)$subuser->id,
'setup_required' => true,
'can_resend_invite' => true,
],
'invite' => $invite,
]);
}, [
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
'edit_subusers' => 'Resend chauffeur invites for any customer (superuser).',
]);
$this->post('/subusers/invite/resend', function () {
@@ -16,7 +16,7 @@ class superuserReplicationRoute
$this->get('/superuser/replication', function () {
global $response;
$this->requirePermission('superuser_replication_view');
$this->requireClassicSuperuserPermission('superuser_replication_view');
$refresh = $this->toBool($this->getParameter('refresh'), false);
$response->success((new replication_manager())->summary($refresh));
}, [
@@ -26,7 +26,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/databases', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
@@ -36,7 +36,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/redis', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
@@ -46,7 +46,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/minio', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
@@ -56,7 +56,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/compose-template', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
}, [
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
@@ -65,7 +65,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/test-credentials', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$parameters = $this->getParametersAsArray();
$response->success((new replication_manager())->testCredentials(
(string)($parameters['kind'] ?? ''),
@@ -78,7 +78,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/test', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success((new replication_manager())->testHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
@@ -91,7 +91,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
try {
$result = (new replication_manager())->provisionHost(
(string)$this->fromRoute('kind'),
@@ -113,7 +113,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
global $response;
$this->requirePermission('superuser_replication_promote');
$this->requireClassicSuperuserPermission('superuser_replication_promote');
try {
$response->success((new replication_manager())->promoteHost(
(string)$this->fromRoute('kind'),
@@ -130,7 +130,7 @@ class superuserReplicationRoute
$this->patch('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
$this->requireClassicSuperuserPermission('superuser_replication_manage');
try {
$response->success((new replication_manager())->renameHost(
(string)$this->fromRoute('kind'),
@@ -148,7 +148,7 @@ class superuserReplicationRoute
$this->delete('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requirePermission('superuser_replication_remove');
$this->requireClassicSuperuserPermission('superuser_replication_remove');
try {
$response->success((new replication_manager())->removeHost(
(string)$this->fromRoute('kind'),
@@ -163,6 +163,23 @@ class superuserReplicationRoute
]);
}
/**
* Replication controls alter infrastructure state and must only be used by
* a classic superuser session. Subuser bearer tokens can carry a delegated
* customer context via X-Customer-Number, so do not allow them to fall back
* to plain string user permission checks for these routes.
*/
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage replication.', 403);
}
return $this->requirePermission($permission);
}
private function routeId(): int
{
$id = (int)$this->fromRoute('id');
+3 -1
View File
@@ -4,6 +4,7 @@ namespace routes;
use classes\db;
use classes\economic;
use classes\release_manager;
use classes\router;
use classes\shelly;
use classes\slack;
@@ -133,6 +134,7 @@ class workerRoute
'timezone' => date_default_timezone_get(),
'host' => gethostname(),
'version' => '1.0.1',
'api_commit_sha' => release_manager::backendCommitSha(),
'routes' => $router->countRoutes(),
'redis' => [
'host' => $REDIS_CONFIG['host'],
@@ -299,4 +301,4 @@ class workerRoute
// Convert to uppercase
return strtoupper($cleaned);
}
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('detaches an order booking and clears the matching order link', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Detached Booking Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Detached Booking Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'order_id' => $order['id'],
]);
api_test_runtime()->db()->query(
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
);
$session = api_fixtures()->createUserSession([
'edit_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->put('/order-bookings', [
'id' => $booking['id'],
'order_id' => null,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toHaveKey('order_id')
->and($response->data()['order_id'])
->toBeNull();
$result = api_test_runtime()->db()->query(
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
);
expect($result)->not->toBeFalse();
$row = $result->fetch_assoc();
expect($row)->toBeArray();
expect($row['booking_order_id'] ?? null)->toBeNull();
expect($row['order_booking_id'] ?? null)->toBeNull();
});
it('keeps the linked order when order_id is omitted from an order booking update', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'order_id' => $order['id'],
'reference' => 'ORIGINAL-BOOKING-REF',
]);
api_test_runtime()->db()->query(
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
);
$session = api_fixtures()->createUserSession([
'edit_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->put('/order-bookings', [
'id' => $booking['id'],
'reference' => 'UPDATED-BOOKING-REF',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['order_id'] ?? 0))->toBe($order['id']);
expect($response->data()['reference'] ?? null)->toBe('UPDATED-BOOKING-REF');
$result = api_test_runtime()->db()->query(
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
);
expect($result)->not->toBeFalse();
$row = $result->fetch_assoc();
expect($row)->toBeArray();
expect((int)($row['booking_order_id'] ?? 0))->toBe($order['id']);
expect((int)($row['order_booking_id'] ?? 0))->toBe($booking['id']);
});
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Customer']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'NOTE-REQUIRED',
]);
$product = api_fixtures()->createProduct([
'id' => 902701,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
api_client()
->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => ' ',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
$response = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
'notes' => 'Graffiti removal on left side',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
});
it('does not allow clearing notes for order items whose product requires notes', function (): void {
api_test_covers('PUT /order/items', 'validation');
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'cashier_id' => $cashier['id'],
'reference' => 'NOTE-EDIT',
]);
$product = api_fixtures()->createProduct([
'id' => 902702,
'name' => 'API Note Required Product',
'price' => 199,
'requires_note' => 1,
]);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
'cashier_id' => $cashier['id'],
'price' => 199,
'quantity' => 1,
'notes' => 'Initial note',
]);
$session = api_fixtures()->createUserSession(['edit_order_items']);
api_client()
->put('/order/items', [
'id' => $orderItem['id'],
'price' => 199,
'quantity' => 1,
'reference' => '',
'notes' => '',
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Notes is required for this product');
});
it('returns the extraordinary chemistry product with requires_note enabled', function (): void {
api_test_covers('GET /products', 'happy');
$product = api_fixtures()->createProduct([
'id' => 902703,
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
'price' => 299,
'requires_note' => 0,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
$response = api_client()->get('/products?id=' . $product['id'], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
+3 -1
View File
@@ -17,5 +17,7 @@ it('returns the ping contract', function (): void {
expect($response->data())
->toBeArray()
->toHaveKey('message', 'pong')
->toHaveKey('time');
->toHaveKey('time')
->toHaveKey('backend_version')
->toHaveKey('api_commit_sha');
});
@@ -2,6 +2,25 @@
usesApiSuite();
function selfserve_fixture_ensure_legacy_redis_constant(): void
{
if (defined('redis')) {
return;
}
global $REDIS_CONFIG;
$REDIS_CONFIG = [
'host' => 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());
}
it('creates a comprehensive self-serve API scenario with demo relays', function (): void {
$scenario = api_fixtures()->createSelfServeScenario();
@@ -21,3 +40,67 @@ it('creates a comprehensive self-serve API scenario with demo relays', function
->and($session)->not->toBeNull()
->and($session['reg'])->toBe($scenario['vehicle']['reg']);
});
it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void {
selfserve_fixture_ensure_legacy_redis_constant();
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']);
$scenario = api_fixtures()->createSelfServeScenario();
$subuser = api_fixtures()->createSubuser([
'name' => 'Self-Serve Driver',
'username' => 'selfserve-driver-' . $scenario['vehicle']['reg'],
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
api_fixtures()->setModuleConfig('selfserve', 'minute_product', (string)$scenario['product']['id'], 'int');
$lane = (new \classes\selfserve())->lane((int)$scenario['lane']['id']);
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
$lane->setLaneMode(\modules\selfserve\helpers\selfserve_lane_mode::MANUAL);
$lane->setCustomerNumber((int)$scenario['customer']['customer_number']);
$lane->setLicensePlate((string)$scenario['vehicle']['reg']);
$lane->setWashStartTime(time() - 620);
$arguments = (new \modules\selfserve\classes\selfserve_lane_command_arguments())
->setCustomerNumber((int)$scenario['customer']['customer_number'])
->setSubuserId((int)$subuser['id']);
expect($lane->invoice($arguments))->toBeTrue();
$orderId = $lane->getLastInvoiceOrderId();
expect($orderId)->toBeInt()->toBeGreaterThan(0);
$order = api_fixtures()->fetchRowById('orders', $orderId);
$attachmentObjectType = '`orders`';
$invoiceCollectionId = (int)($order['invoice_collection_id'] ?? 0);
if ($invoiceCollectionId > 0) {
api_fixtures()->cleanupDeleteById('collected_order_invoices', $invoiceCollectionId);
}
api_fixtures()->cleanupDeleteById('orders', $orderId);
api_fixtures()->cleanupDeleteWhere('order_items', ['order_id' => $orderId]);
api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]);
expect($order)->not->toBeNull()
->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number'])
->and((int)$order['department_id'])->toBe((int)$scenario['department']['id'])
->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg'])
->and((int)$order['lane'])->toBe((int)$scenario['lane']['id'])
->and($order['completed_at'])->toBeNull();
$db = api_test_runtime()->db();
$result = $db->query(
"SELECT content FROM object_attachments WHERE object_type = '{$attachmentObjectType}' AND object_id = " . (int)$orderId . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1'
);
$attachment = $result ? $result->fetch_assoc() : null;
$content = json_decode((string)($attachment['content'] ?? ''), true);
$metadata = is_array($content) ? ($content['other'] ?? null) : null;
expect($metadata)->toBeArray()
->and($metadata['type'] ?? null)->toBe(\attachments\helpers\attachment_content::OTHER_TYPE_SELF_SERVE_WASH)
->and((int)($metadata['customer_number'] ?? 0))->toBe((int)$scenario['customer']['customer_number'])
->and((int)($metadata['draft_customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number'])
->and((int)($metadata['subuser_id'] ?? 0))->toBe((int)$subuser['id'])
->and((int)($metadata['session_id'] ?? 0))->toBe((int)$scenario['session']['id'])
->and($metadata['subuser']['name'] ?? null)->toBe('Self-Serve Driver');
});
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
usesApiSuite();
const SUBUSER_PASSWORD_POLICY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
it('lists subusers when an existing grant has legacy zero permissions', function (): void {
api_test_covers('GET /subusers', 'happy');
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$subuser = api_fixtures()->createSubuser([
'name' => 'Legacy Permission Driver',
]);
$grantId = api_fixtures()->grantSubuser(
$subuser['id'],
$session['user']['customer_number'],
['VEHICLES_LIST']
);
$legacyPermissions = '0';
$statement = api_test_runtime()->db()->prepare(
'UPDATE `subuser_grants` SET `permissions` = ? WHERE `id` = ?'
);
$statement->bind_param('si', $legacyPermissions, $grantId);
$statement->execute();
$statement->close();
$response = api_client()->get('/subusers?page=1&limit=5&include_non_enabled=true', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$matchingSubusers = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id']
));
expect($matchingSubusers)->toHaveCount(1);
expect($matchingSubusers[0]['grant_permissions'] ?? null)->toBe([]);
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
});
it('uses the same password policy for subuser setup and password auth', function (): void {
api_test_covers('POST /subusers/setup', 'failure');
api_test_covers('POST /subusers/auth/password', 'failure');
$subuser = api_fixtures()->createSubuser();
$setupResponse = api_client()->post('/subusers/setup', [
'token' => 'policy-test-token',
'name' => 'Policy Driver',
'password' => 'invalidpassword',
]);
$authResponse = api_client()->post('/subusers/auth/password', [
'subuser_id' => $subuser['id'],
'password' => 'invalidpassword',
]);
$setupResponse
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
$authResponse
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
});
it('lists chauffeur grants across customers for superusers', function (): void {
$session = api_fixtures()->createUserSession(['list_subusers']);
$firstCustomer = api_fixtures()->createUser([
'display_name' => 'Fleet Customer Alpha',
'economic_customer_name' => 'Fleet Customer Alpha',
]);
$secondCustomer = api_fixtures()->createUser([
'display_name' => 'Fleet Customer Beta',
'economic_customer_name' => 'Fleet Customer Beta',
]);
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
$firstGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST', 'SUBUSERS_LIST']
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$secondSubuser['id'],
(int)$secondCustomer['customer_number'],
['BOOKINGS_LIST']
);
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$rows = array_values(array_filter(
is_array($response->data()) ? $response->data() : [],
static fn (mixed $item): bool => is_array($item)
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
));
expect($rows)->toHaveCount(2);
$byGrantId = [];
foreach ($rows as $row) {
$byGrantId[(int)$row['grant_id']] = $row;
}
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
$session = api_fixtures()->createUserSession(['add_subusers']);
$customer = api_fixtures()->createUser([
'display_name' => 'Invite Target Customer',
'economic_customer_name' => 'Invite Target Customer',
]);
$phone = 71000000 + ((int)$customer['customer_number'] % 1000000);
$createdSubuserId = null;
$createdGrantId = null;
$setupToken = null;
try {
$response = api_client()->post('/superuser/subusers/invite', [
'customer_number' => (int)$customer['customer_number'],
'name' => 'Invited Driver',
'phone_country_code' => 45,
'phone' => $phone,
'permissions' => ['VEHICLES_LIST'],
'note' => 'Created by superuser test',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
expect($payload['subuser']['customer_number'] ?? null)->toBe((int)$customer['customer_number']);
expect($payload['subuser']['name'] ?? null)->toBe('Invited Driver');
expect($payload['subuser']['grant_permissions'] ?? null)->toBe(['VEHICLES_LIST']);
expect($payload['grant']['note'] ?? null)->toBe('Created by superuser test');
expect($payload['invite']['setup_link'] ?? null)->toBeString();
} finally {
if ($setupToken !== null && $setupToken !== '') {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
if ($createdGrantId !== null) {
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
}
if ($createdSubuserId !== null) {
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
}
}
});
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('returns the running API commit in worker status', function (): void {
api_test_covers('GET /worker/status', 'happy');
$response = api_client()->get('/worker/status');
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('api_commit_sha');
expect($response->data()['api_commit_sha'])
->toBeString()
->not->toBe('');
});
@@ -384,8 +384,7 @@ function edge_gateway_integration_context(): array
}
};
$db = new db($dbConfig);
$db->connect();
$db = edge_gateway_integration_wait_for_db($dbConfig);
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
@@ -412,6 +411,25 @@ function edge_gateway_integration_context(): array
];
}
function edge_gateway_integration_wait_for_db(array $dbConfig): db
{
$deadline = microtime(true) + 60;
$lastError = null;
do {
try {
$db = new db($dbConfig);
$db->connect();
return $db;
} catch (RuntimeException $exception) {
$lastError = $exception;
usleep(500000);
}
} while (microtime(true) < $deadline);
throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.');
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
@@ -61,6 +61,8 @@ final class ApiFixtures
'updated_at' => $attributes['updated_at'] ?? $now,
]);
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
$this->cleanup->add(function () use ($userId, $customerNumber): void {
$this->purgeCustomerTraceData($userId, $customerNumber);
$this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber);
@@ -70,6 +72,7 @@ final class ApiFixtures
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
});
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
@@ -535,6 +538,48 @@ final class ApiFixtures
return ['id' => $categoryId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createProduct(array $attributes = []): array
{
$categoryId = (int)($attributes['category'] ?? 0);
if ($categoryId <= 0) {
$category = $this->createCategory();
$categoryId = (int)$category['id'];
}
$productData = [
'name' => (string)($attributes['name'] ?? ('API Product ' . $this->uniqueSuffix())),
'description' => (string)($attributes['description'] ?? 'API product'),
'price' => (int)($attributes['price'] ?? 100),
'subscription_allowed' => (int)($attributes['subscription_allowed'] ?? 1),
'category' => $categoryId,
'piktogram' => $attributes['piktogram'] ?? 'truck',
'economic_product_id' => $attributes['economic_product_id'] ?? 0,
'apply_category_discount' => (int)($attributes['apply_category_discount'] ?? 0),
'requires_note' => (int)($attributes['requires_note'] ?? 0),
'is_wash' => (int)($attributes['is_wash'] ?? 0),
'display_in_booking_form' => (int)($attributes['display_in_booking_form'] ?? 1),
'order_priority' => (int)($attributes['order_priority'] ?? 0),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
];
if (isset($attributes['id'])) {
$productData = ['id' => (int)$attributes['id']] + $productData;
}
$productId = $this->insertRowWithExistingColumns('products', $productData);
$this->deleteRedisPattern('obj_prop:products:' . $productId . ':*');
$this->cleanup->add(fn() => $this->deleteById('products', $productId));
$this->cleanup->add(fn() => $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*'));
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []);
}
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
{
$linkId = $this->insertRow('department_categories', [
@@ -38,8 +38,9 @@ namespace {
expect($content)->toContain("switch (\$dynamic_image_id)");
});
it('accepts ordered dynamic image button tokens including reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([
it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([
'program_picker',
'reset',
0,
2,
@@ -50,7 +51,7 @@ namespace {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('ordered highlighted button IDs');
expect($route)->toContain('"reset", or "start"');
expect($route)->toContain('"reset", "start", or "program_picker"');
});
it('renders machine one dynamic image steps from the ordered button payload', function (): void {
@@ -60,13 +61,14 @@ namespace {
expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();');
expect($content)->toContain('getOrderedHighlightedButtonTokens');
expect($content)->toContain('normalizeHighlightedButtonToken');
expect($content)->toContain("const BUTTON_PROGRAM_PICKER = 'program_picker'");
expect($content)->toContain('getProgramPickerStepCoordinates');
expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)');
expect($content)->toContain('self::BUTTON_PROGRAM_PICKER');
$thumbOffset = strpos($content, 'public function drawStepThumb');
expect($thumbOffset)->not->toBeFalse();
$thumbBody = substr($content, (int)$thumbOffset, 1800);
expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE');
expect($thumbBody)->not->toContain('drawStepCounterOnButton');
expect($thumbBody)->not->toContain('only_generate_current_step');
$setupOffset = strpos($content, 'public function setup');
expect($setupOffset)->not->toBeFalse();
$setupBody = substr($content, (int)$setupOffset, 1000);
expect($setupBody)->not->toContain('drawStepThumb();');
});
}
@@ -0,0 +1,66 @@
<?php
use classes\cors_policy;
app_require('classes/cors_policy.php');
it('normalizes URL-like CORS entries to origins', function (): void {
expect(cors_policy::normalizeOrigin('https://api-v2.truckwash.io/master/api'))
->toBe('https://api-v2.truckwash.io');
expect(cors_policy::normalizeOrigin('https://API-V2.TRUCKWASH.IO/canary/api/'))
->toBe('https://api-v2.truckwash.io');
expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping'))
->toBe('https://api.truckwash.io:4433');
});
it('merges required release and existing frontend origins into configured CORS', function (): void {
$origins = cors_policy::allowedOrigins('https://example.test/app,https://api-v2.truckwash.io/master/api');
expect($origins)->toContain('https://api-v2.truckwash.io');
expect($origins)->toContain('http://localhost:5173');
expect($origins)->toContain('https://truckwash.io');
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
});
it('builds credential-safe normal CORS response headers for allowed origins', function (): void {
$headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io');
expect($headers['Access-Control-Allow-Origin'])->toBe('http://localhost:5173');
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH');
expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace');
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
expect($headers['Access-Control-Max-Age'])->toBe('86400');
expect($headers['Vary'])->toBe('Origin');
});
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
$preflight = cors_policy::preflightResponse(
'https://api-v2.truckwash.io/master/api',
'https://truckwash.io'
);
expect($preflight['allowed'])->toBeTrue();
expect($preflight['status'])->toBe(200);
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
expect($preflight['headers']['Content-Type'])->toBe('application/json');
expect($preflight['body'])->toBe('');
});
it('rejects unknown CORS origins', function (): void {
$headers = cors_policy::responseHeaders('https://evil.example.test', 'https://truckwash.io');
$preflight = cors_policy::preflightResponse('https://evil.example.test', 'https://truckwash.io');
expect($headers)->toBe([]);
expect($preflight['allowed'])->toBeFalse();
expect($preflight['status'])->toBe(403);
expect($preflight['body'])->toContain('CORS origin not allowed');
});
it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void {
$headers = cors_policy::responseHeaders('https://partner.example.test', '*');
expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test');
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
});
@@ -10,9 +10,8 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
];
$files = [
app_path('index.php'),
app_path('classes/cors_policy.php'),
app_path('modules/washcertificates/index.php'),
app_path('routes/optionsRoute.php'),
];
foreach ($files as $file) {
@@ -23,4 +22,7 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
expect($content)->toContain($header);
}
}
expect((string)file_get_contents(app_path('index.php')))->toContain('cors_policy::preflightResponse');
expect((string)file_get_contents(app_path('routes/optionsRoute.php')))->toContain('cors_policy::preflightResponse');
});
@@ -123,7 +123,120 @@ it('marks a channel ready when all release services are healthy', function (): v
->and($channel['services'])->toHaveCount(5);
});
it('reports non-default channels missing bundles, versions, and URLs as blocking missing values', function (): void {
it('does not block channels on unhealthy data targets when data services are production shared', function (): void {
$database = releaseStatusReadyService('database', 71);
$database['deployment_status'] = 'reconcile_failed';
$database['availability_state'] = 'degraded';
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://beta.example.test',
'api_base_url' => 'https://api-beta.example.test',
'versions' => releaseStatusReadyVersions(8),
],
],
'deployment_targets' => [
['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
],
'service_sets' => [
[
'id' => 53,
'channel_id' => 2,
'mode' => 'attach_existing',
'stack' => [
'database' => $database,
'redis' => releaseStatusReadyService('redis', 75),
'minio' => releaseStatusReadyService('minio', 76),
],
],
],
'deployments' => [
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['issues'])->toBe([])
->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([
'status' => 'production_shared',
'state' => 'ready',
'severity' => 'ok',
'issue_type' => null,
]);
});
it('marks beta ready from the production frontend and API services', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 1,
'slug' => 'stable',
'name' => 'Stable',
'default_channel' => true,
'enabled' => true,
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
'versions' => releaseStatusReadyVersions(),
],
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'],
],
],
],
'deployment_targets' => [
['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'],
['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'],
],
'deployments' => [
['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['service_policy'])->toBe('production_shared')
->and($channel['service_channel_slug'])->toBe('stable')
->and($channel['missing_values'])->toBe([])
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'frontend_base_url' => 'https://app.example.test',
'api_base_url' => 'https://api.example.test',
])
->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
])
->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([
'status' => 'production_shared',
'service_policy' => 'production_shared',
'service_channel_slug' => 'stable',
]);
});
it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
@@ -150,11 +263,13 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
$channel = releaseStatusChannelBySlug($overview, 'beta');
$missingLabels = array_column($channel['missing_values'], 'label');
$missingKeys = array_column($channel['missing_values'], 'key');
expect($overview['state'])->toBe('blocked')
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(5)
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4)
->and($channel['readiness'])->toBe('blocked')
->and($missingLabels)->toContain('release bundle')
->and($missingKeys)->not->toContain('release_bundle')
->and($missingLabels)->not->toContain('release bundle')
->and($missingLabels)->toContain('frontend version')
->and($missingLabels)->toContain('frontend URL')
->and($missingLabels)->toContain('API version')
@@ -166,6 +281,150 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
]);
});
it('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void {
$versions = releaseStatusReadyVersions(0);
unset($versions['bundle_id'], $versions['bundle_label']);
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend',
'api_base_url' => 'https://api-v2.truckwash.io/beta/api',
'versions' => $versions,
],
],
'deployment_targets' => [
['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
],
'deployments' => [
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($overview['state'])->toBe('ready')
->and($channel['readiness'])->toBe('ready')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'status' => 'ready',
])
->and($channel['issues'])->toBe([])
->and($channel['missing_values'])->toBe([]);
});
it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => releaseStatusReadyVersions(),
],
],
'deployment_targets' => [
[
'id' => 21,
'channel_id' => 2,
'channel_slug' => 'beta',
'app' => 'frontend',
'repository' => 'truckwash/front-end-vue',
'branch' => 'master',
'coolify_service_uuid' => 'frontend-beta-service',
'deploy_context' => [
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
],
],
[
'id' => 22,
'channel_id' => 2,
'channel_slug' => 'beta',
'app' => 'api',
'repository' => 'truckwash/backend-php',
'branch' => 'master',
'coolify_service_uuid' => 'api-beta-service',
'deploy_context' => [
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
],
],
],
'service_sets' => [
[
'id' => 31,
'channel_id' => 2,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 41),
'redis' => releaseStatusReadyService('redis', 42),
'minio' => releaseStatusReadyService('minio', 43),
],
],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
$missingKeys = array_column($channel['missing_values'], 'key');
expect($missingKeys)->not->toContain('frontend_base_url')
->and($missingKeys)->not->toContain('api_base_url')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'frontend_base_url' => 'https://gateway.example.test/beta/frontend',
'api_base_url' => 'https://gateway.example.test/beta/api',
])
->and($channel['readiness'])->toBe('ready');
});
it('does not surface legacy release bundle actions as readiness blockers', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 20,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://beta.example.test',
'api_base_url' => 'https://api-beta.example.test',
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => ['release_bundle'],
],
],
],
'bundles' => [
['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'],
['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'status' => 'ready',
])
->and($channel['issues'])->toBe([])
->and($channel['missing_values'])->toBe([]);
});
it('surfaces failed latest deployments as critical promotion blockers', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
@@ -232,6 +491,73 @@ it('surfaces failed latest deployments as critical promotion blockers', function
'type' => 'failed_deployment',
'deployment_id' => 57,
'next_action' => 'Open the deployment logs and fix composer dependencies.',
])
->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:')
->and($channel['issues'][0]['impact'])->toContain('cannot be promoted')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment');
});
it('offers application target preparation for path routed Coolify failures', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 30,
'slug' => 'internal',
'name' => 'Internal',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://internal.example.test',
'api_base_url' => 'https://api-internal.example.test',
'versions' => releaseStatusReadyVersions(19),
'availability' => [
'configured' => true,
'status' => 'ready',
'missing' => [],
],
],
],
'deployment_targets' => [
['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'],
['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'],
],
'service_sets' => [
[
'id' => 83,
'channel_id' => 30,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 84),
'redis' => releaseStatusReadyService('redis', 85),
'minio' => releaseStatusReadyService('minio', 86),
],
],
],
'deployments' => [
[
'id' => 87,
'channel_id' => 30,
'app' => 'api',
'status' => 'failed',
'failure_summary' => [
'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.',
],
],
['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'internal');
$actions = $channel['issues'][0]['actions'];
$actionIds = array_column($actions, 'id');
$prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)];
expect($actionIds)->toContain('retry_deployment')
->and($actionIds)->toContain('prepare_application_target')
->and($prepareAction)->toMatchArray([
'requires_confirmation' => true,
'requires_input' => false,
'disabled_reason' => '',
]);
});
@@ -289,7 +615,8 @@ it('flags isolated stacks that are missing database Redis and MinIO services', f
'severity' => 'critical',
'issue_type' => 'missing_value',
'missing_key' => 'minio_service',
]);
])
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services');
});
it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void {
@@ -346,5 +673,8 @@ it('maps degraded Coolify targets and reconcile failures to release service issu
'severity' => 'critical',
'type' => 'service_unhealthy',
'service_key' => 'database',
]);
])
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target');
});
@@ -39,6 +39,63 @@ it('verifies GitHub sha256 webhook signatures', function (): void {
expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse();
});
it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void {
$previous = getenv('RELEASE_MANAGER_GATE_TOKEN');
try {
putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret');
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret';
$manager = new release_manager();
expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue();
expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse();
expect($manager->verifyReleaseGateToken(''))->toBeFalse();
} finally {
if ($previous === false) {
putenv('RELEASE_MANAGER_GATE_TOKEN');
unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']);
} else {
putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous);
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous;
}
}
});
it('normalizes app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
$normalizeGate->setAccessible(true);
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
$appMatches->setAccessible(true);
$gate = $normalizeGate->invoke($manager, [
'channel_slug' => 'stable',
'app' => 'api',
'repository' => 'https://github.com/copenhagentruckwash/api.git',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
], ['slug' => 'stable']);
expect($gate)->toMatchArray([
'channel_slug' => 'stable',
'route_slug' => 'master',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
'auto_sync' => true,
]);
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
});
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
@@ -250,6 +307,7 @@ it('builds explicit Coolify application route labels for release API targets', f
->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
@@ -328,7 +386,279 @@ it('does not treat an existing Coolify service as an application just because a
], 'existing-application-uuid'))->toBe('application');
});
it('keeps release branch services out of the production Coolify environment except beta', function (): void {
it('auto-prepares path-routed release targets for Coolify application creation', function (): void {
$manager = new release_manager();
$needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate');
$needsApplication->setAccessible(true);
$target = [
'id' => 42,
'channel_slug' => 'stable',
'app' => 'api',
'coolify_instance_id' => 7,
'coolify_service_uuid' => '',
'deploy_context_json' => null,
];
expect($needsApplication->invoke($manager, $target, []))->toBeTrue();
expect($needsApplication->invoke($manager, array_replace($target, [
'coolify_service_uuid' => 'existing-service',
]), []))->toBeFalse();
expect($needsApplication->invoke($manager, array_replace($target, [
'coolify_instance_id' => null,
]), []))->toBeFalse();
expect($needsApplication->invoke($manager, $target, [
'coolify_auto_create' => true,
]))->toBeFalse();
});
it('offers application target preparation for missing Coolify service creation failures', function (): void {
$needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget');
$needsApplicationAction->setAccessible(true);
expect($needsApplicationAction->invoke(null, [
'message' => 'API deployment failed before activation.',
'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.',
]))->toBeTrue();
expect($needsApplicationAction->invoke(null, [
'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
'next_action' => 'Migrate this target before deploying.',
]))->toBeTrue();
expect($needsApplicationAction->invoke(null, [
'message' => 'Release gate failed.',
'next_action' => 'Run live smoke tests before promotion.',
]))->toBeFalse();
});
it('builds API Coolify runtime environment from allowed process variables', function (): void {
$keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = getenv($key);
}
try {
putenv('CONFIG_DB_HOST=db.example.test');
$_ENV['CONFIG_DB_HOST'] = 'db.example.test';
$_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
putenv('CONFIG_DB_PASSWORD=runtime-secret');
$_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret';
$_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret';
putenv('EDGE_BROKER_URL=https://edge.example.test');
$_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test';
$_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test';
putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api');
$_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
$_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
putenv('PATH=/should/not/copy');
$_ENV['PATH'] = '/should/not/copy';
$_SERVER['PATH'] = '/should/not/copy';
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
], [
'coolify_env' => [
'CONFIG_DB_HOST' => 'context-db.example.test',
'CUSTOM_ALLOWED' => 'from-context',
],
]);
expect($env['USE_ENV'])->toBe('true');
expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test');
expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret');
expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test');
expect($env['CUSTOM_ALLOWED'])->toBe('from-context');
expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io');
expect(explode(',', $env['CORS']))->toContain('http://localhost:5173');
expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api');
expect($env)->not->toHaveKey('PATH');
} finally {
foreach ($previous as $key => $value) {
if ($value === false) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
});
it('resolves backend commit sha from API runtime environment in priority order', function (): void {
$keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = [
'process' => getenv($key),
'env_set' => array_key_exists($key, $_ENV),
'env' => $_ENV[$key] ?? null,
'server_set' => array_key_exists($key, $_SERVER),
'server' => $_SERVER[$key] ?? null,
];
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
}
$set = static function (string $key, string $value): void {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
};
try {
$set('API_COMMIT_SHA', 'not-a-sha');
$set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
$set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc');
expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
$set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
} finally {
foreach ($previous as $key => $state) {
if ($state['process'] === false) {
putenv($key);
} else {
putenv($key . '=' . $state['process']);
}
if ($state['env_set']) {
$_ENV[$key] = $state['env'];
} else {
unset($_ENV[$key]);
}
if ($state['server_set']) {
$_SERVER[$key] = $state['server'];
} else {
unset($_SERVER[$key]);
}
}
}
});
it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '1111111111111111111111111111111111111111';
$explicitCommit = '2222222222222222222222222222222222222222';
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
'commit_sha' => $selectedCommit,
], [
'coolify_env' => [
'COMMIT_SHA' => $explicitCommit,
],
]);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
});
it('keeps beta API runtime environment on production database target', function (): void {
$keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = getenv($key);
}
try {
putenv('CONFIG_DB_TARGET=production');
$_ENV['CONFIG_DB_TARGET'] = 'production';
$_SERVER['CONFIG_DB_TARGET'] = 'production';
putenv('CONFIG_DB_HOST=prod-db.example.test');
$_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test';
$_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test';
putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test');
$_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
$_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
putenv('DEBUG=false');
$_ENV['DEBUG'] = 'false';
$_SERVER['DEBUG'] = 'false';
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
'channel_slug' => 'beta',
], []);
expect($env['CONFIG_DB_TARGET'])->toBe('production');
expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test');
expect($env['DEBUG'])->toBe('false');
expect($env['CONFIG_DB_TARGET'])->not->toBe('debug');
} finally {
foreach ($previous as $key => $value) {
if ($value === false) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
});
it('treats attach-existing service sets without data target ids as production-shared ready', function (): void {
$manager = new release_manager();
$status = new ReflectionMethod(release_manager::class, 'serviceSetStatus');
$status->setAccessible(true);
$dataTargets = ['database' => null, 'redis' => null, 'minio' => null];
expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready');
expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets');
expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty');
expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets');
});
it('detects explicit data target ids so beta service sets can stay data-only', function (): void {
$manager = new release_manager();
$hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets');
$hasExplicitDataTargets->setAccessible(true);
expect($hasExplicitDataTargets->invoke($manager, [
'mode' => 'attach_existing',
'data_source_service_set_id' => 10,
]))->toBeFalse();
expect($hasExplicitDataTargets->invoke($manager, [
'data_targets' => [
'database' => 42,
],
]))->toBeTrue();
expect($hasExplicitDataTargets->invoke($manager, [
'redis_coolify_target_id' => 43,
]))->toBeTrue();
});
it('allows beta production-service bundles only when data services stay production-shared', function (): void {
$manager = new release_manager();
$assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
$assert->setAccessible(true);
$betaChannel = ['id' => 2, 'slug' => 'beta'];
expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull();
foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) {
expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode]))
->toThrow(RuntimeException::class, 'production-shared');
}
});
it('keeps release branch services out of the production Coolify environment', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
@@ -367,8 +697,8 @@ it('keeps release branch services out of the production Coolify environment exce
'default_server_uuid' => 'server-default',
]);
expect($betaPayload['environment_name'])->toBe('production');
expect($betaPayload['environment_uuid'])->toBe('env-production');
expect($betaPayload['environment_name'])->toBe('release-beta');
expect($betaPayload)->not->toHaveKey('environment_uuid');
});
it('does not invent GHCR images for Coolify service payloads', function (): void {
@@ -424,15 +754,20 @@ it('defines release manager schema, routes, permissions, and system-status integ
$response = file_get_contents(app_path('classes/response.php'));
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
$index = file_get_contents(app_path('index.php'));
$corsPolicy = file_get_contents(app_path('classes/cors_policy.php'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
@@ -443,9 +778,13 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'");
expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'");
expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'");
expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'");
expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'");
expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
expect($schema)->toContain("['stable', 'Stable'");
@@ -457,12 +796,18 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/release/runtime');
expect($route)->toContain('/release/timeline/events');
expect($route)->toContain('/release/github/webhook');
expect($route)->toContain('/release/gate/test-runs');
expect($route)->toContain('releaseGateToken');
expect($route)->toContain('/superuser/releases/config');
expect($route)->toContain('/superuser/releases/github/repositories');
expect($route)->toContain('/superuser/releases/github/branches');
expect($route)->toContain('/superuser/releases/github/commits');
expect($route)->toContain('/superuser/releases/github/test');
expect($route)->toContain('/superuser/releases/channels');
expect($route)->toContain('/superuser/releases/channels/{id}/sync');
expect($route)->toContain('/superuser/releases/test-runs');
expect($route)->toContain('/superuser/releases/operations');
expect($route)->toContain('/superuser/releases/operations/{id}');
expect($route)->toContain('/superuser/releases/channels/{id}/bundle');
expect($route)->toContain('/superuser/releases/assignment-subjects');
expect($route)->toContain('/superuser/releases/assignments');
@@ -473,6 +818,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
expect($route)->toContain('/superuser/releases/deployments');
expect($route)->toContain('/superuser/releases/issues/actions');
expect($route)->toContain('/superuser/releases/replay-targets');
expect($route)->toContain('/superuser/releases/timeline/sessions');
expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}');
@@ -484,6 +830,17 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain("requirePermission('superuser_release_manager_replay')");
expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('verifyReleaseGateToken');
expect($manager)->toContain('normalizeReleaseGateInput');
expect($manager)->toContain('processReleaseGateAutoSync');
expect($manager)->toContain('release_auto_sync_events');
expect($manager)->toContain('require_readiness');
expect($manager)->toContain('release-manifest.json');
expect($manager)->toContain('static_artifact');
expect($manager)->toContain('api_gateway');
expect($manager)->toContain('assertReleaseGatePassedForPromotion');
expect($manager)->toContain('release_gate_required_for_promotion');
expect($manager)->toContain("'public_smoke_required' => true");
expect($manager)->toContain('normalizeGithubRepositoryName');
expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'");
expect($manager)->toContain('releaseConfig');
@@ -512,6 +869,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('searchAssignmentSubjects');
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
expect($manager)->toContain('available_channels');
expect($manager)->toContain("'source' => 'deployment'");
expect($manager)->toContain('chooseRuntimeChannel');
expect($manager)->toContain('requestedRuntimeChannelSlug');
expect($manager)->toContain("status = 'superseded'");
@@ -531,6 +889,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain("'replica_failover' => false");
expect($manager)->toContain('deploymentCanBePromoted');
expect($manager)->toContain('deploymentFailureSummary');
expect($manager)->toContain('runIssueAction');
expect($manager)->toContain('release_issue_action_attempted');
expect($manager)->toContain('prepareReleaseIssueApplicationTarget');
expect($manager)->toContain('coolifyProjectSuggestions');
expect($manager)->toContain('releaseCoolifyServerUuid');
expect($manager)->toContain('coolify_project_uuid');
@@ -564,6 +925,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('coolify_enable_ssl');
expect($manager)->toContain('createService');
expect($manager)->toContain('updateService');
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
expect($manager)->toContain('channel_presets');
expect($manager)->toContain('target_presets');
@@ -572,10 +934,20 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($status)->toContain("'key' => 'releasemanager'");
expect($status)->toContain('probeReleaseManagerModule');
expect($index)->toContain('release_manager::initializeRequestContext');
expect($index)->toContain('X-Release-Trace');
expect($corsPolicy)->toContain('X-Release-Trace');
expect($manager)->toContain('X-Release-Channel');
expect($manager)->toContain('normalizeReleaseApiIngressPath');
expect($manager)->toContain('routeSlugForChannel');
expect($manager)->toContain('channelSlugForRoute');
expect($manager)->toContain('syncChannel');
expect($manager)->toContain('runReleaseTest');
expect($manager)->toContain('releaseTestAppsFromInput');
expect($manager)->toContain('release_operation_runs');
expect($manager)->toContain('active_channel_app_key');
expect($manager)->toContain('production_shared');
expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false');
expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false');
});
it('captures release request context from headers and runtime query parameters', function (): void {
@@ -657,77 +1029,80 @@ it('normalizes channel-prefixed API ingress paths before route dispatch', functi
}
});
it('allows explicit runtime selection of any enabled release channel', function (): void {
$db = $GLOBALS['db'] ?? null;
it('maps the public master API prefix to the stable release channel', function (): void {
$server = $_SERVER;
$get = $_GET;
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
try {
$GLOBALS['db'] = new class {
public function prepare(string $sql): object
{
return new class {
/** @var array<int,mixed> */
private array $params = [];
$_SERVER['REQUEST_URI'] = '/master/api/release/runtime';
$_GET = [];
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
'trace_id' => 'trace-master',
'requested_channel' => '',
'frontend_version' => '',
'backend_version' => 'test',
'request_started_at' => date('c'),
'original_request_uri' => '/master/api/release/runtime',
'normalized_request_uri' => '',
'ingress_prefix_stripped' => false,
];
public function bind_param(string $types, mixed &...$params): void
{
$this->params = $params;
}
$normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']);
public function execute(): void
{
}
public function get_result(): object
{
$slug = (string)($this->params[0] ?? '');
return new class($slug) {
public function __construct(private readonly string $slug)
{
}
public function fetch_all(int $mode): array
{
if ($this->slug !== 'internal') {
return [];
}
return [[
'id' => 4,
'slug' => 'internal',
'name' => 'Internal',
'enabled' => 1,
'default_channel' => 0,
'deleted_at' => null,
]];
}
};
}
};
}
};
$manager = new release_manager();
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$chooseRuntimeChannel->setAccessible(true);
$channel = $chooseRuntimeChannel->invoke($manager, [
'id' => 1,
'slug' => 'stable',
'enabled' => 1,
'default_channel' => 1,
], [], 'internal');
expect($channel['slug'])->toBe('internal');
expect($normalized)->toMatchArray([
'route_slug' => 'master',
'channel_slug' => 'stable',
'normalized_request_uri' => '/release/runtime',
'normalized_path' => '/release/runtime',
]);
expect($_GET['release_channel'])->toBe('stable');
expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
'requested_channel' => 'stable',
'release_route_slug' => 'master',
'ingress_prefix_stripped' => true,
]);
} finally {
if ($db === null) {
unset($GLOBALS['db']);
$_SERVER = $server;
$_GET = $get;
if ($context === null) {
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
} else {
$GLOBALS['db'] = $db;
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
}
}
});
it('uses master as the public route slug for the stable release channel', function (): void {
expect(release_manager::routeSlugForChannel('stable'))->toBe('master');
expect(release_manager::routeSlugForChannel('beta'))->toBe('beta');
expect(release_manager::channelSlugForRoute('master'))->toBe('stable');
expect(release_manager::channelSlugForRoute('internal'))->toBe('internal');
});
it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
$manager = new release_manager();
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$chooseRuntimeChannel->setAccessible(true);
$stable = [
'id' => 1,
'slug' => 'stable',
'enabled' => 1,
'default_channel' => 1,
];
$internal = [
'id' => 4,
'slug' => 'internal',
'name' => 'Internal',
'enabled' => 1,
'default_channel' => 0,
];
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable);
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal);
});
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
$manager = new release_manager();
$availability = new ReflectionMethod(release_manager::class, 'channelAvailability');
@@ -830,6 +1205,7 @@ it('requires non-default release channel runtime URLs and preserves load balance
'letsencrypt'
);
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
@@ -842,11 +1218,103 @@ it('requires non-default release channel runtime URLs and preserves load balance
expect($methodSource)->toContain('frontend_base_url');
expect($methodSource)->toContain('api_base_url');
expect($methodSource)->toContain('release_bundle');
expect($methodSource)->not->toContain("missing[] = 'release_bundle'");
expect($methodSource)->toContain('frontend_version');
expect($methodSource)->toContain('api_version');
});
it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void {
$manager = new release_manager();
$endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint');
$endpoint->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$manual = $endpoint->invoke($manager, [
'app' => 'api',
'channel_slug' => 'beta',
'deploy_context' => [
'endpoint_mode' => 'manual',
'manual_endpoint_host' => 'manual-api.example.test',
'manual_endpoint_port' => '8443',
],
]);
expect($manual)->toMatchArray([
'mode' => 'manual',
'status' => 'resolved',
'host' => 'manual-api.example.test',
'port' => 8443,
'url' => 'https://manual-api.example.test:8443',
'source' => 'manual',
]);
expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [
'endpoint_mode' => 'manual',
'manual_endpoint_host' => 'manual-api.example.test',
'manual_endpoint_port' => '8443',
]))->toBe('https://manual-api.example.test:8443');
$fromPublicUrl = $endpoint->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'internal',
'deploy_context' => [
'endpoint_mode' => 'auto',
'coolify_public_url' => 'https://api-v2.truckwash.io',
],
]);
expect($fromPublicUrl)->toMatchArray([
'mode' => 'auto',
'status' => 'resolved',
'host' => 'api-v2.truckwash.io',
'port' => 443,
'url' => 'https://api-v2.truckwash.io/internal/frontend',
'source' => 'coolify_public_url',
]);
$fromHealth = $endpoint->invoke($manager, [
'app' => 'api',
'channel_slug' => 'canary',
'health_url' => 'https://health.example.test/canary/api/ping',
'deploy_context' => [
'endpoint_mode' => 'auto',
'coolify_domain' => 'domain.example.test',
],
]);
expect($fromHealth)->toMatchArray([
'status' => 'resolved',
'host' => 'health.example.test',
'url' => 'https://health.example.test/canary/api',
'source' => 'health_url',
]);
$gateway = $endpoint->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
]),
]);
expect($gateway)->toMatchArray([
'mode' => 'auto',
'status' => 'pending',
'host' => 'gateway.example.test',
'port' => 443,
'url' => 'https://gateway.example.test/beta/frontend',
'source' => 'auto_gateway',
]);
expect($targetPublicBaseUrl->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBe('https://gateway.example.test/beta/frontend');
});
it('exposes release version git commit metadata for runtime channel cards', function (): void {
$manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
@@ -882,8 +1350,7 @@ it('exposes release version git commit metadata for runtime channel cards', func
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
});
it('chooses requested runtime channels from available channels or enabled channel slugs', function (): void {
$db = $GLOBALS['db'] ?? null;
it('only chooses requested runtime channels from channels available to the principal', function (): void {
$manager = new release_manager();
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$choose->setAccessible(true);
@@ -900,43 +1367,20 @@ it('chooses requested runtime channels from available channels or enabled channe
'name' => 'Canary',
'default_channel' => 0,
];
$internal = [
'id' => 3,
'slug' => 'internal',
'name' => 'Internal',
'default_channel' => 0,
'enabled' => 1,
];
try {
$GLOBALS['db'] = new class {
public function prepare(string $sql): object
{
return new class {
public function bind_param(string $types, mixed &...$params): void
{
}
public function execute(): void
{
}
public function get_result(): object
{
return new class {
public function fetch_all(int $mode): array
{
return [];
}
};
}
};
}
};
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
} finally {
if ($db === null) {
unset($GLOBALS['db']);
} else {
$GLOBALS['db'] = $db;
}
}
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
expect($internal['enabled'])->toBe(1);
});
it('normalizes release assignment subject suggestions without leaking private fields', function (): void {
@@ -551,8 +551,9 @@ it('wires MinIO replication through routes and bootstrap snapshots', function ()
expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh');
expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])');
expect($manager)->toContain('stale targets do not keep a healthy current target below 100%');
expect($manager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
expect($manager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
$normalizedManager = str_replace("\r\n", "\n", $manager);
expect($normalizedManager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
expect($normalizedManager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
expect($manager)->toContain('private function minioBucketStats(');
expect($manager)->toContain("'minio' => [");
expect($routes)->toContain("/superuser/replication/minio");
@@ -14,10 +14,10 @@ it('registers superuser replication endpoints and permissions', function (): voi
expect($content)->toContain('/superuser/replication/{kind}/{id}/provision');
expect($content)->toContain('/superuser/replication/{kind}/{id}/promote');
expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'");
expect($content)->toContain("requirePermission('superuser_replication_view')");
expect($content)->toContain("requirePermission('superuser_replication_manage')");
expect($content)->toContain("requirePermission('superuser_replication_promote')");
expect($content)->toContain("requirePermission('superuser_replication_remove')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_view')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')");
});
it('documents replication management in openapi', function (): void {
@@ -36,3 +36,16 @@ it('documents replication management in openapi', function (): void {
expect($content)->toContain('SuperuserReplicationHostRenameRequest');
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
});
it('rejects subuser sessions before checking replication permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool');
expect($content)->toContain('get_subuser() !== false');
expect($content)->toContain("Subuser sessions cannot manage replication.");
expect($content)->toContain("\$response->error('Subuser sessions cannot manage replication.', 403);");
expect($content)->toContain('return $this->requirePermission($permission);');
expect(preg_match_all("/requireClassicSuperuserPermission\\('superuser_replication_/", $content))->toBe(11);
expect($content)->not->toContain("requirePermission('superuser_replication_");
});
@@ -68,10 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
expect($composeSource)->toContain('version: "2.4"');
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started");
$normalizedComposeSource = str_replace("\r\n", "\n", $composeSource);
expect($normalizedComposeSource)->toContain('version: "2.4"');
expect($normalizedComposeSource)->toContain('condition: service_healthy');
expect($normalizedComposeSource)->toContain("minio:\n condition: service_started");
expect($normalizedComposeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
@@ -0,0 +1,159 @@
<?php
app_require('routes/moduleSelfServeRoute.php');
app_require('modules/selfserve/classes/selfserve_lane.php');
app_require('objects/department_lanes_o.php');
app_require('classes/object_property.php');
use classes\object_property;
use modules\selfserve\classes\selfserve_lane;
use objects\department_lanes_o;
use routes\moduleSelfServeRoute;
class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute
{
/** @var array<string,bool> */
public array $permissions = [
'list_own_department_selfserve_vehicle_conditions' => true,
];
public bool $laneEnabled = true;
public bool $activeWashResult = false;
/** @var array<int,array{department_id:int,customer_number:int}> */
public array $activeWashChecks = [];
public function __construct()
{
// Avoid route_t request initialization in this focused unit test.
}
public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool
{
$key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission;
return $this->permissions[$key] ?? false;
}
public function canUseLane(selfserve_lane $lane, int $customer_number): bool
{
return $this->canCustomerUseSelfServeLane($lane, $customer_number);
}
public function canUseActiveLane(selfserve_lane $lane, int $customer_number): bool
{
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
}
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
{
return $this->laneEnabled;
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
{
$this->activeWashChecks[] = [
'department_id' => $department_id,
'customer_number' => $customer_number,
];
return $this->activeWashResult;
}
}
class SelfserveCustomerLaneAccessDepartmentLaneFake extends department_lanes_o
{
public function __construct(int $department_id)
{
$this->id = -1;
$this->department = new object_property('department_lanes', -1, 'department', 'int');
$this->department->set($department_id);
}
public function structure(): void
{
// Skip database bootstrap for this unit test.
}
}
class SelfserveCustomerLaneAccessLaneFake extends selfserve_lane
{
public ?int $fakeCustomerNumber = null;
public function __construct(int $department_id, ?int $customer_number = null)
{
$this->id = 91;
$this->fakeCustomerNumber = $customer_number;
$this->department_lane = new SelfserveCustomerLaneAccessDepartmentLaneFake($department_id);
}
public function getCustomerNumber(): ?int
{
return $this->fakeCustomerNumber;
}
}
it('allows customers with own self-serve permission to use enabled self-serve lanes', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
expect($route->canUseLane($lane, 12345679))->toBeTrue();
});
it('blocks customer lane mutations without own self-serve permission', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$route->permissions = [];
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
expect($route->canUseLane($lane, 12345679))->toBeFalse();
});
it('blocks customer lane mutations when the lane is not operationally enabled', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$route->laneEnabled = false;
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
expect($route->canUseLane($lane, 12345679))->toBeFalse();
});
it('allows active wash operations when the lane runtime belongs to the customer', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679);
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
expect($route->activeWashChecks)->toBe([]);
});
it('keeps active wash operations available if a lane is disabled after start', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$route->laneEnabled = false;
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679);
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
expect($route->activeWashChecks)->toBe([]);
});
it('falls back to active department sessions for customer active wash operations', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$route->activeWashResult = true;
$lane = new SelfserveCustomerLaneAccessLaneFake(4, null);
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
expect($route->activeWashChecks)->toBe([
[
'department_id' => 4,
'customer_number' => 12345679,
],
]);
});
it('blocks active wash operations for other customers', function (): void {
$route = new SelfserveCustomerLaneAccessRouteHarness();
$route->activeWashResult = false;
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 99999999);
expect($route->canUseActiveLane($lane, 12345679))->toBeFalse();
expect($route->activeWashChecks)->toBe([
[
'department_id' => 4,
'customer_number' => 12345679,
],
]);
});
@@ -24,6 +24,7 @@ class SelfserveLaneInvoiceModeBillingHarness
public ?int $lastAddedOrderId = null;
public ?int $lastAddedProductId = null;
public ?int $lastAddedQuantity = null;
public int $createdOrderContexts = 0;
private selfserve_lane_status $laneStatus = selfserve_lane_status::OCCUPIED;
private selfserve_lane_mode $laneMode = selfserve_lane_mode::MANUAL;
@@ -85,6 +86,7 @@ class SelfserveLaneInvoiceModeBillingHarness
protected function createInvoiceOrderContext(): orders_o
{
$this->createdOrderContexts++;
$order = new SelfserveLaneInvoiceModeOrderStub();
$order->id = 424242;
$this->last_invoice_order_id = (int)$order->id;
@@ -111,6 +113,7 @@ it('bills manual self-serve stop using full elapsed minutes without included-min
expect($harness->lastAddedOrderId)->toBe(424242);
expect($harness->lastAddedProductId)->toBe(999);
expect($harness->lastAddedQuantity)->toBe(1);
expect($harness->createdOrderContexts)->toBe(1);
expect($harness->getLastInvoiceOrderId())->toBe(424242);
});
@@ -126,6 +129,6 @@ it('keeps included-minute reduction for automatic mode', function (): void {
expect($harness->lastAddedOrderId)->toBeNull();
expect($harness->lastAddedProductId)->toBeNull();
expect($harness->lastAddedQuantity)->toBeNull();
expect($harness->createdOrderContexts)->toBe(0);
expect($harness->getLastInvoiceOrderId())->toBeNull();
});
@@ -76,6 +76,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('/department/selfserve/studio/simulate:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
expect($content)->toContain('/department/selfserve/studio/path-confirmations:');
expect($content)->toContain('/department/selfserve/studio/publish:');
expect($content)->toContain('/department/selfserve/studio/rollback:');
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
@@ -86,8 +87,11 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
expect($content)->toContain('SelfserveStudioPathResult:');
expect($content)->toContain('SelfserveStudioPathProgress:');
expect($content)->toContain('SelfserveStudioPathConfirmationRequest:');
expect($content)->toContain('SelfserveStudioPathConfirmation:');
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
expect($content)->toContain('confirmSelfserveStudioPath');
expect($content)->toContain('runSelfserveStudioGatewayAction');
});
@@ -13,14 +13,29 @@ use routes\moduleSelfServeRoute;
class SelfservePropertyGatePermissionBypassRouteHarness extends moduleSelfServeRoute
{
public bool $activeWashResult = false;
/** @var array<string,bool> */
public array $permissions = [
'list_own_department_selfserve_vehicle_conditions' => true,
];
/** @var array<int,array{department_id:int,customer_number:int}> */
public array $activeWashChecks = [];
public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool
{
$key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission;
return $this->permissions[$key] ?? false;
}
public function canUsePropertyGate(selfserve_lane $lane, int $customer_number): bool
{
return $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
}
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
{
return true;
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
{
$this->activeWashChecks[] = [
@@ -81,6 +96,16 @@ it('does not bypass property gate permissions without a positive customer number
expect($route->activeWashChecks)->toBe([]);
});
it('does not bypass property gate permissions without customer self-serve permission', function (): void {
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
$route->activeWashResult = true;
$route->permissions = [];
$lane = selfserve_property_gate_permission_lane_for_department(6);
expect($route->canUsePropertyGate($lane, 12345679))->toBeFalse();
expect($route->activeWashChecks)->toBe([]);
});
it('does not bypass property gate permissions when the customer has no active wash in the target department', function (): void {
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
$route->activeWashResult = false;
@@ -108,16 +108,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations');
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
expect($studioRoute)->toContain('projectPathOutcomes');
expect($studioRoute)->toContain('confirmPathOutcome');
expect($studioRoute)->toContain('modules_shelly_config');
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
expect($studioGraph)->not->toBeFalse();
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
expect($studioGraph)->toContain('buildGatewayWorkspace');
expect($studioGraph)->toContain('runGatewayAction');
expect($studioGraph)->toContain('layout_affects_runtime');
@@ -166,6 +169,32 @@ it('wires machine relay status get and set endpoints', function (): void {
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
});
it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void {
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($lanesRoute)->not->toBeFalse()
->and($lanesRoute)->toContain('/department/lanes/status-toggles')
->and($lanesRoute)->toContain('LIST_DEPARTMENT_LANE_STATUS_TOGGLES')
->and($lanesRoute)->toContain('getDepartmentLanes($department_id)');
expect($laneObject)->not->toBeFalse()
->and($laneObject)->toContain("'machine_status_enabled' => self::isOperationalStatusName(\$status)")
->and($laneObject)->toContain("'dognvask_configured' => \$selfserve_configuration_warnings === []")
->and($laneObject)->toContain("'dognvask_configuration_warnings' => \$selfserve_configuration_warnings")
->and($laneObject)->toContain('public function getSelfServeConfigurationWarnings(): array')
->and($laneObject)->toContain('relay_machine_program_picker_id')
->and($laneObject)->toContain('machine_type_id');
expect($moduleSelfServeRoute)->not->toBeFalse()
->and($moduleSelfServeRoute)->toContain("\$this->put('/modules/self-serve/lane/status'")
->and($moduleSelfServeRoute)->toContain('modules_selfserve_lane_status_set')
->and($moduleSelfServeRoute)->toContain('setLaneStatus($target_status)')
->and($moduleSelfServeRoute)->toContain('selfserve_lane_status::MAINTENANCE')
->and($moduleSelfServeRoute)->toContain('machine_status_enabled');
});
it('applies Shelly transport overrides across self-serve relay side-effect routes', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
@@ -50,3 +50,13 @@ it('uses mysql-safe identifiers for self-serve studio virtual hardware storage',
expect(strlen($identifier))->toBeLessThanOrEqual(64);
}
});
it('creates self-serve studio path confirmation storage', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations');
expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL');
expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature');
});
@@ -22,6 +22,11 @@ it('keeps cleaner relay enable wired into machine relay start paths', function (
expect($enableMachineRelayMethodOffset)->not->toBeFalse();
$enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200);
expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);');
$cleanerEnableOffset = strpos($enableMachineRelayMethod, '$this->enableCleanerRelayForStartedWash($lane);');
$alreadyEnabledGuardOffset = strpos($enableMachineRelayMethod, 'if ((bool)$session->machine_relay_enabled->value() === true)');
expect($cleanerEnableOffset)->not->toBeFalse()
->and($alreadyEnabledGuardOffset)->not->toBeFalse()
->and($cleanerEnableOffset)->toBeLessThan($alreadyEnabledGuardOffset);
expect($moduleRoute)->not->toBeFalse();
$machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable');
@@ -370,6 +370,168 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
expect($studioGraphSource)->toContain('upsert_path');
});
it('upserts path editor answers into generated condition and task config rows', function (): void {
$service = selfserve_studio_graph_without_constructor();
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
$method->setAccessible(true);
$config = [
'schema_version' => 2,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
],
'conditions' => [],
'rules' => [],
'tasks' => [],
'actions' => [],
'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
];
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'reset', 2, 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
[
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker'],
'dynamic_images_vehicle_type' => 3,
],
[
'task' => 'Press reset',
'services' => ['MACHINE'],
'buttons' => ['reset'],
],
[
'task' => 'Press machine button 2',
'services' => ['MACHINE'],
'buttons' => [2],
],
[
'task' => 'Press start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
],
],
]]);
$condition = $config['conditions'][0] ?? [];
$tasks = array_values($config['tasks'] ?? []);
$pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
$validation = (new class extends selfserve_config_versioning {
public function __construct()
{
}
})->validateConfig($config);
expect($condition['generated_by'])->toBe('path_editor')
->and($condition['path_key'])->toBe('allowed_front')
->and($condition['lane'])->toBe(7)
->and($condition['product'])->toBe(2)
->and($condition['machine_type_id'])->toBe(1001)
->and($condition['expression']['children'])->toHaveCount(2)
->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
->and($tasks)->toHaveCount(4)
->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
->and($tasks[0]['generated_by'])->toBe('path_editor')
->and($tasks[0]['gate_type'])->toBe('CONDITION')
->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
->and($tasks[0]['buttons'])->toBe(['program_picker'])
->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
->and($tasks[1]['buttons'])->toBe(['reset'])
->and($tasks[2]['buttons'])->toBe([2])
->and($tasks[3]['buttons'])->toBe(['start'])
->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
->and($pathMeta['path_signature'])->not->toBe('')
->and($validation['valid'])->toBeTrue();
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'allowed_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => true],
['question_id' => 12, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Set program',
'services' => ['MACHINE', 'PROGRAM_PICKER'],
'buttons' => ['program_picker', 'start'],
'dynamic_images_vehicle_type' => 3,
'tasks' => [
['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
],
],
],
]]);
$updatedTasks = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
));
$updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
expect($updatedTasks)->toHaveCount(2)
->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);
$method->invokeArgs($service, [6, &$config, [
'action' => 'upsert_path',
'entity' => 'path',
'data' => [
'path_key' => 'legacy_front',
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
'answers' => [
['question_id' => 11, 'value' => false],
],
'result' => [
'machine_allowed' => true,
'task' => 'Legacy start',
'services' => ['MACHINE'],
'buttons' => ['start'],
],
],
]]);
$legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
$legacyTask = array_values(array_filter(
$config['tasks'] ?? [],
static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
))[0] ?? [];
expect($legacyTask['task'])->toBe('Legacy start')
->and($legacyTask['buttons'])->toBe(['start'])
->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
});
it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
@@ -623,12 +785,266 @@ it('builds guided simulator debug payload with blockers and canvas annotations',
->and($debug['questions'][0]['state'])->toBe('missing')
->and($debug['questions'][0]['answer_source'])->toBe('override')
->and($debug['tasks'][0]['state'])->toBe('blocked')
->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.')
->and($debug['tasks'][0]['causes'][0])->toMatchArray([
'kind' => 'question',
'id' => 11,
'label' => 'Are mirrors folded?',
'expected' => true,
'actual' => null,
])
->and($debug['dynamic_image_buttons'][0])->toMatchArray([
'kind' => 'dynamic_image_button',
'label' => '1',
'state' => 'hidden',
'task_id' => 41,
])
->and(array_column($debug['decisions'], 'kind'))->toContain('question')
->and(array_column($debug['decisions'], 'kind'))->toContain('task')
->and(array_column($debug['decisions'], 'kind'))->toContain('signal')
->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button')
->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions')
->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay')
->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning')
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
});
it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => [
'id' => 7,
'department' => 6,
'name' => 'Lane 7',
'relay_machine_id' => 'M-7',
'relay_machine_program_picker_id' => 'PICKER-7',
],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 4,
'answers' => [],
'questions' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'],
'machine_available' => true,
'all_visible_questions_answered' => true,
'allowed' => true,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [],
'visibility_condition_results' => [],
'condition_results' => [],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
],
],
'debug_candidates' => [
'questions' => [],
'conditions' => [],
'rules' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['4' => 'Program 4'],
'machine_types' => ['1001' => 'Portal'],
'tasks' => ['41' => 'Choose program', '42' => 'Press first button'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
],
],
],
],
]);
$dynamicImageDecisions = array_values(array_filter(
$debug['decisions'],
static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button'
));
expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0])
->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0'])
->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']);
});
it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => [
'id' => 7,
'department' => 6,
'name' => 'Lane 7',
'relay_machine_id' => 'M-7',
],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 2,
'answers' => [11 => false],
'answer_sources' => [11 => 'override'],
'questions' => [],
'tasks' => [],
'allowed_services' => [],
'machine_available' => true,
'all_visible_questions_answered' => true,
'allowed' => false,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [11],
'visibility_condition_results' => [21 => false],
'condition_results' => [21 => false],
'visibility_expression_traces' => [
21 => [
'type' => 'condition',
'condition_id' => 21,
'result' => false,
'expression' => [
'type' => 'group',
'operator' => 'ALL',
'result' => false,
'children' => [
[
'type' => 'predicate',
'subject_type' => 'question',
'subject_id' => 11,
'operator' => 'IS_TRUE',
'actual_value' => false,
'result' => false,
'reason' => 'Predicate did not pass.',
],
],
'reason' => 'Group did not pass.',
],
],
],
'condition_expression_traces' => [
21 => [
'type' => 'condition',
'condition_id' => 21,
'result' => false,
'expression' => [
'type' => 'predicate',
'subject_type' => 'question',
'subject_id' => 11,
'operator' => 'IS_TRUE',
'actual_value' => false,
'result' => false,
'reason' => 'Predicate did not pass.',
],
],
],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
],
],
'debug_candidates' => [
'questions' => [
['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2],
],
'conditions' => [
['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
],
'rules' => [
['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
],
'tasks' => [
['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1],
],
'actions' => [
[
'id' => 81,
'name' => 'Open entry on start',
'event' => 'wash_start_command',
'wash_mode' => 'both',
'operation' => 'open_lane_entrance_port',
'condition_id' => 21,
'order_priority' => 1,
],
],
'visible_answers' => [11 => false],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['2' => 'Forvogn'],
'machine_types' => ['1001' => 'Portal'],
'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'],
'conditions' => ['21' => 'Trailer present'],
'tasks' => ['41' => 'Fold mirrors'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
],
],
],
],
]);
$hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? [];
$taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? [];
$actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? [];
$signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? [];
$buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? [];
expect($hiddenQuestion['state'])->toBe('hidden')
->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.')
->and($debug['conditions'][0]['causes'][0])->toMatchArray([
'kind' => 'question',
'id' => 11,
'label' => 'Are mirrors folded?',
'expected' => true,
'actual' => false,
])
->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.')
->and($actionDecision['state'])->toBe('skipped')
->and($actionDecision['causes'][0])->toMatchArray([
'kind' => 'condition',
'id' => 21,
'label' => 'Trailer present',
'expected' => true,
'actual' => false,
])
->and($signalDecision['causes'][0]['reason'])->toContain('blocked')
->and($buttonDecision['state'])->toBe('hidden')
->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors');
});
it('projects visible question answer paths into grouped task service and signal outcomes', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {
@@ -762,10 +1178,93 @@ it('projects visible question answer paths into grouped task service and signal
->and($allowedPath['answers'])->toHaveCount(2)
->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
->and($allowedPath['services'])->toBe(['MACHINE'])
->and($allowedPath['path_signature'])->not->toBe('')
->and($allowedPath['result_signature'])->not->toBe('')
->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
->and($allowedPath['node_ids'])->toContain('question:11')
->and($allowedPath['node_ids'])->toContain('task:41');
});
it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = static function (string $button): callable {
return static function (array $overrides) use ($button): array {
$answers = [];
foreach ($overrides as $entry) {
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
}
$allowed = ($answers[11] ?? null) === true;
return [
'allowed' => $allowed,
'questions' => [
['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
],
'tasks' => $allowed ? [
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
] : [],
'allowed_services' => $allowed ? ['MACHINE'] : [],
'debug' => [
'questions' => [
['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
],
'tasks' => [
['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
],
'signal_timeline' => [],
],
];
};
};
$scope = [
'department_id' => 6,
'lane_id' => 7,
'vehicle_type_id' => 2,
'config_source' => 'draft',
'config_version_id' => 90,
'hardware_mode' => 'studio',
];
$initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
$allowedPath = array_values(array_filter(
$initial['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$rows = [[
'path_signature' => $allowedPath['path_signature'],
'result_signature' => $allowedPath['result_signature'],
'answers' => $allowedPath['answers'],
'result' => ['allowed' => true],
'scope' => $scope,
'confirmed_at' => '2026-05-27 10:00:00',
'confirmed_by' => 9,
]];
$confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
'scope' => $scope,
'confirmation_rows' => $rows,
]);
$confirmedAllowed = array_values(array_filter(
$confirmed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
$staleAllowed = array_values(array_filter(
$changed['paths'],
static fn(array $path): bool => ($path['allowed'] ?? false) === true
))[0] ?? [];
expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
->and($staleAllowed['confirmation_status'])->toBe('stale')
->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
->and($changed['summary']['confirmations']['stale'])->toBe(1);
});
it('truncates path outcome projection when the state cap is reached', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {
@@ -1,5 +1,79 @@
<?php
app_require('classes/object_property.php');
app_require('objects/department_lanes_o.php');
app_require('modules/selfserve/classes/selfserve_lane.php');
app_require('modules/selfserve/classes/selfserve_wash_flow.php');
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_relay;
class SelfserveWashCompletionRelayValueFake extends \classes\object_property
{
public function __construct(private readonly mixed $storedValue) {}
public function value(): mixed
{
return $this->storedValue;
}
}
class SelfserveWashCompletionDepartmentLaneFake extends \objects\department_lanes_o
{
public function __construct(string $machineRelayId = 'relay-machine', string $cleanerRelayId = 'relay-cleaner')
{
$this->relay_machine_id = new SelfserveWashCompletionRelayValueFake($machineRelayId);
$this->relay_machine_cleaner_id = new SelfserveWashCompletionRelayValueFake($cleanerRelayId);
$this->relay_machine_program_picker_id = new SelfserveWashCompletionRelayValueFake('');
}
public function exists(): bool
{
return true;
}
}
class SelfserveWashCompletionRelayLaneFake extends selfserve_lane
{
/** @var selfserve_lane_relay[] */
public array $statusReads = [];
/** @var array<int,array{0:selfserve_lane_relay,1:bool}> */
public array $relayWrites = [];
public function __construct(bool $reportedOn)
{
$this->id = 77;
$this->reportedOn = $reportedOn;
$this->department_lane = new SelfserveWashCompletionDepartmentLaneFake();
}
private bool $reportedOn;
public function getRelayStatus(selfserve_lane_relay $relay): array
{
$this->statusReads[] = $relay;
return ['on' => $this->reportedOn];
}
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool
{
$this->relayWrites[] = [$relay, $on];
return true;
}
}
class SelfserveWashCompletionFlowHarness extends selfserve_wash_flow
{
public function __construct() {}
public function turnOffConfiguredRelay(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
$this->turnOffRelayIfConfigured($lane, $relay);
}
}
it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void {
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
@@ -10,14 +84,27 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
expect($methodOffset)->not->toBeFalse();
$methodBody = substr($washFlow, (int)$methodOffset, 1500);
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);');
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);');
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfiguredAndOn');
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfigured');
expect($helperOffset)->not->toBeFalse();
$helperBody = substr($washFlow, (int)$helperOffset, 1500);
expect($helperBody)->toContain('$status = $lane->getRelayStatus($relay);');
expect($helperBody)->toContain("if ((bool)(\$status['on'] ?? false) !== true)");
expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);');
expect($helperBody)->not->toContain('$lane->getRelayStatus($relay)');
});
it('always dispatches completion relay off for configured machine relays without a status precheck', function (): void {
$lane = new SelfserveWashCompletionRelayLaneFake(reportedOn: false);
$flow = new SelfserveWashCompletionFlowHarness();
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE);
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE_CLEANER);
expect($lane->statusReads)->toBe([])
->and($lane->relayWrites)->toBe([
[selfserve_lane_relay::MACHINE, false],
[selfserve_lane_relay::MACHINE_CLEANER, false],
]);
});
@@ -0,0 +1,25 @@
<?php
use objects\subuser_grants_o;
it('keeps default subuser grant permissions JSON encodable', function (): void {
$encoded = json_encode(subuser_grants_o::defaultPermissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
expect($encoded)->toBeString();
expect(json_decode($encoded, true))
->toContain('VEHICLES_LIST')
->toContain('SELFSERVE_ADD')
->toContain('BOOKINGS_LIST');
});
it('normalizes stored subuser grant permission payloads', function (): void {
expect(subuser_grants_o::normalizePermissionsValue(0))->toBe([]);
expect(subuser_grants_o::normalizePermissionsValue('0'))->toBe([]);
expect(subuser_grants_o::normalizePermissionsValue('["vehicles_list","BOOKINGS_ADD"]'))
->toBe(['VEHICLES_LIST', 'BOOKINGS_ADD']);
expect(subuser_grants_o::normalizePermissionsValue([
'VEHICLES_LIST' => true,
'BOOKINGS_DELETE' => false,
'UNKNOWN_PERMISSION' => true,
]))->toBe(['VEHICLES_LIST']);
});
@@ -0,0 +1,20 @@
<?php
use objects\subusers_o;
it('accepts subuser passwords that match the shared policy', function (): void {
subusers_o::assertValidPassword('Test1234');
expect(true)->toBeTrue();
});
it('rejects subuser passwords that do not match the shared policy', function (string $password): void {
expect(fn () => subusers_o::assertValidPassword($password))
->toThrow(Exception::class, 'Password must be between 8 and 255 characters long');
})->with([
'too short' => ['Tes123'],
'missing uppercase' => ['test1234'],
'missing lowercase' => ['TEST1234'],
'missing number' => ['TestPassword'],
'too long' => [str_repeat('A', 256) . 'a1'],
]);
@@ -9,6 +9,9 @@ it('exposes chauffeur management endpoints on the subusers route', function ():
expect($normalized)->toContain("\$this->post('/subusers/invite', function () {");
expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {");
expect($normalized)->toContain("\$this->get('/superuser/subusers', function () {");
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite', function () {");
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {");
expect($normalized)->toContain("\$this->put('/subusers', function () {");
expect($normalized)->toContain("\$this->put('/subusers/me', function () {");
});
@@ -31,6 +34,20 @@ it('includes grant management fields in the subusers payload builder', function
expect($normalized)->toContain("'access_state' =>");
});
it('resolves subuser customer names without external lookups during list requests', function (): void {
$routeFile = app_path('routes/subusersRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain('getCustomerNames($customerNumbers, false)');
expect($normalized)->toContain('$customerName = $this->resolveCustomerName($customerNumber);');
expect($normalized)->toContain('buildSubuserManagementPayload($subuser, $customerNumber, $customerName)');
expect($normalized)->not->toContain("'customer_name' => (new users_o())->getCustomerName(\$customerNumber)");
expect($normalized)->not->toContain("'name' => (new users_o())->getCustomerName((int)\$grant['billing_customer_number'])");
});
it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void {
$routeFile = app_path('routes/subusersRoute.php');
expect(is_file($routeFile))->toBeTrue();
@@ -77,7 +77,7 @@ class _TestLane extends selfserve_lane {
protected function hasMachineStartSignalForStop(): bool { return true; }
protected function completeLatestSessionForStop(): void {}
public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { return true; }
public function invoice(): bool { return true; }
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool { return true; }
public function logLaneAction(\modules\selfserve\helpers\selfserve_lane_log_action $action, int $status_code = 200, array $extra_data = []): void { /* no-op */ }
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool { if ($relay === selfserve_lane_relay::MACHINE && $on === false) { $this->relayOffCalled = true; } return true; }
public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->relayOffCalled = true; return true; }
+5 -1
View File
@@ -28,7 +28,7 @@ http:
- main: api.truckwash.dk
api-preflight-io:
rule: (Host(`api.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
rule: (Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
entryPoints: [websecure, websecure-staging]
middlewares: [secure-headers]
service: noop@internal
@@ -37,6 +37,7 @@ http:
certResolver: le_io
domains:
- main: api.truckwash.io
- main: api-v2.truckwash.io
cloud-preflight:
rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`)
@@ -82,6 +83,7 @@ http:
- "https://www.truckwash.io"
- "https://api.truckwash.io"
- "https://api.truckwash.io:4433"
- "https://api-v2.truckwash.io"
- "https://web.truckwash.dk"
- "https://api.truckwash.dk"
- "https://truckwash.dk"
@@ -107,6 +109,8 @@ http:
- X-Release-Trace
- X-Release-Channel
- X-Frontend-Version
- Cache-Control
- Pragma
api-ratelimit:
rateLimit:
average: 100