Compare commits

...
6 Commits
35 changed files with 5876 additions and 297 deletions
+3
View File
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
CONFIG_DB_PORT=3306 CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED 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) # Debug DB credentials (used when CONFIG_DB_TARGET=debug)
# Any blank debug value falls back to the live value above. # Any blank debug value falls back to the live value above.
CONFIG_DB_DEBUG_HOST=mysql-debug 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
+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) $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 $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 $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 = [ $ECONOMIC_API = [
'app_access_grant' => '', // Economic API access grant token (1) 'app_access_grant' => '', // Economic API access grant token (1)
'app_access_grant2' => '', // Economic API access grant token (2) 'app_access_grant2' => '', // Economic API access grant token (2)
+200 -4
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations description: License plate scanning operations
- name: Config - name: Config
description: Module configuration management description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding - name: Branding
description: Branding options management description: Branding options management
- name: Roles - name: Roles
@@ -4030,13 +4032,15 @@ paths:
- name: buttons - name: buttons
in: query in: query
required: false 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: schema:
oneOf: oneOf:
- type: string - type: string
- type: array - type: array
items: items:
type: integer oneOf:
- type: integer
- type: string
- name: current_step - name: current_step
in: query in: query
required: false required: false
@@ -5844,7 +5848,7 @@ paths:
reference: {type: string} reference: {type: string}
po: {type: string} po: {type: string}
pickup: {type: boolean} pickup: {type: boolean}
order_id: {type: integer} order_id: {type: integer, nullable: true}
items: items:
type: array type: array
items: items:
@@ -8953,6 +8957,10 @@ paths:
description: | description: |
Send a command (e.g., start, stop, reset) to a self-serve lane. 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 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 operationId: sendSelfServeLaneCommand
requestBody: requestBody:
required: true required: true
@@ -9003,6 +9011,9 @@ paths:
Updates the set of services that are allowed to be manually activated for a given self-serve lane, 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. 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. 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 operationId: setSelfServeLaneAllowedServices
requestBody: requestBody:
required: true required: true
@@ -9446,7 +9457,9 @@ paths:
description: | description: |
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services 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 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` Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud. forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
operationId: enableSelfServeLaneMachineRelay operationId: enableSelfServeLaneMachineRelay
@@ -11982,6 +11995,189 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $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: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
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: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:12 2026] 127.0.0.1:41964 Accepted
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing [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 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 = []; $data = [];
foreach ($env as $key => $value) { foreach ($env as $key => $value) {
@@ -113,11 +135,7 @@ class coolify_api_client
]; ];
} }
if ($data === []) { return $data;
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
} }
public function deployResource(string $uuid, bool $force = false): array 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
@@ -157,6 +157,7 @@ class release_manager_schema_bootstrap
bundle_id BIGINT UNSIGNED NULL, bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app', deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL, app VARCHAR(16) NOT NULL,
active_channel_app_key VARCHAR(96) NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify', provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL, repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL, branch VARCHAR(128) NULL,
@@ -297,6 +298,47 @@ class release_manager_schema_bootstrap
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at) INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) 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 ( "CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL, channel_id BIGINT UNSIGNED NULL,
@@ -321,6 +363,7 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id'); 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', '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', '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', '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_name', 'VARCHAR(64) NULL AFTER device_type');
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name'); self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
@@ -336,9 +379,12 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha'); 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_device', 'device_type');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label'); 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', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string'); 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_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string'); self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int'); self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
@@ -365,6 +411,8 @@ class release_manager_schema_bootstrap
'release_service_sets', 'release_service_sets',
'release_deployments', 'release_deployments',
'release_bundles', 'release_bundles',
'release_operation_runs',
'release_operation_steps',
'release_timeline_sessions', 'release_timeline_sessions',
'release_timeline_events', 'release_timeline_events',
] as $table) { ] as $table) {
@@ -457,4 +505,23 @@ class release_manager_schema_bootstrap
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)"); $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)");
}
} }
+6 -18
View File
@@ -10,30 +10,18 @@ const WD = __DIR__;
require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php'; require_once 'config.php';
require_once __DIR__ . '/classes/cors_policy.php';
/** CORS */ /** 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 // OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) { $preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
header("Access-Control-Allow-Origin: " . ($origin ?: '*')); \classes\cors_policy::emitHeaders($preflight['headers']);
header("Access-Control-Allow-Credentials: true"); http_response_code($preflight['status']);
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); echo $preflight['body'];
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
header('Content-Type: application/json');
http_response_code(200);
exit; exit;
}
} }
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
/** Debug */ /** Debug */
if ($DEBUG) { if ($DEBUG) {
ini_set('display_errors', 1); ini_set('display_errors', 1);
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png'; const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
const BUTTON_RESET = 'reset'; const BUTTON_RESET = 'reset';
const BUTTON_START = 'start'; const BUTTON_START = 'start';
const BUTTON_PROGRAM_PICKER = 'program_picker';
// Thumb // Thumb
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees) public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
public int $thumb_size = 1550; // height and width of the thumb 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->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
$this->drawProgramWheel(); $this->drawProgramWheel();
$this->drawThumb(); $this->drawThumb();
$this->drawStepThumb();
$deferredStartButtons = $this->drawHighlightedButtonSequence(); $deferredStartButtons = $this->drawHighlightedButtonSequence();
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0); $this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
$this->drawDeferredHighlightedButtons($deferredStartButtons); $this->drawDeferredHighlightedButtons($deferredStartButtons);
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
if (is_string($button)) { if (is_string($button)) {
$trimmed = trim($button); $trimmed = trim($button);
$specialButton = strtolower($trimmed); $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; return $specialButton;
} }
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
private function getHighlightedButtonCoordinates(int|string $button): ?array private function getHighlightedButtonCoordinates(int|string $button): ?array
{ {
if ($button === self::BUTTON_PROGRAM_PICKER) {
return $this->getProgramPickerStepCoordinates();
}
if ($button === self::BUTTON_RESET) { if ($button === self::BUTTON_RESET) {
return [ return [
'x' => 1736, 'x' => 1736,
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null; 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 * @throws \Exception
*/ */
@@ -841,6 +841,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? [])); $tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups); $actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions); $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); $recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware); $summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups); $stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
@@ -871,6 +881,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'rules' => $rules, 'rules' => $rules,
'tasks' => $tasks, 'tasks' => $tasks,
'actions' => $actions, 'actions' => $actions,
'dynamic_image_buttons' => $dynamicImageButtons,
'decisions' => $decisions,
'hardware' => $hardware, 'hardware' => $hardware,
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
'graph_annotations' => $annotations, 'graph_annotations' => $annotations,
@@ -981,6 +993,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null) $conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_condition_results'] ? (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 = []; $items = [];
foreach ($questions as $question) { foreach ($questions as $question) {
@@ -992,20 +1007,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$visible = isset($visibleIds[$questionId]); $visible = isset($visibleIds[$questionId]);
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null; $answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered'); $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[] = [ $items[] = [
'kind' => 'question',
'id' => $questionId, 'id' => $questionId,
'node_id' => 'question:' . $questionId, 'node_id' => 'question:' . $questionId,
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)), 'node_ids' => ['question:' . $questionId],
'label' => $label,
'visible' => $visible, 'visible' => $visible,
'state' => $state, 'state' => $state,
'answer' => $answer, 'answer' => $answer,
'answer_source' => $sources[$questionId] ?? 'missing', 'answer_source' => $sources[$questionId] ?? 'missing',
'condition_id' => $gateId, 'condition_id' => $gateId,
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId), 'condition' => $conditionLabel,
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true), 'gate_satisfied' => $gateSatisfied,
'reason' => $visible 'reason' => $reason,
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.') 'causes' => $causes,
: 'Visibility condition did not pass.',
'order_priority' => (int)($question['order_priority'] ?? 0), 'order_priority' => (int)($question['order_priority'] ?? 0),
]; ];
} }
@@ -1036,19 +1074,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null); $actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual); $satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions'; $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[] = [ $items[] = [
'kind' => 'rule',
'id' => $ruleId, 'id' => $ruleId,
'node_id' => 'rule:' . $ruleId, 'node_id' => 'rule:' . $ruleId,
'node_ids' => ['rule:' . $ruleId],
'condition_id' => (int)($rule['condition_id'] ?? 0), 'condition_id' => (int)($rule['condition_id'] ?? 0),
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)), 'label' => $label,
'type' => (string)($rule['type'] ?? ''), 'type' => (string)($rule['type'] ?? ''),
'object_type' => $objectType, 'object_type' => $objectType,
'object_id' => $objectId, 'object_id' => $objectId,
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId), 'object_label' => $objectLabel,
'actual_value' => $actual, 'actual_value' => $actual,
'satisfied' => $satisfied, 'satisfied' => $satisfied,
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'), 'invalid_reference' => $invalidReference,
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.', 'reason' => $reason,
'causes' => [
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
],
]; ];
} }
@@ -1082,10 +1134,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []; $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null; $expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups); $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[] = [ $items[] = [
'kind' => 'condition',
'id' => $conditionId, 'id' => $conditionId,
'node_id' => 'condition:' . $conditionId, 'node_id' => 'condition:' . $conditionId,
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)), 'node_ids' => ['condition:' . $conditionId],
'label' => $label,
'result' => $result, 'result' => $result,
'state' => $result ? 'passed' : 'failed', 'state' => $result ? 'passed' : 'failed',
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null), 'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
@@ -1098,6 +1169,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'reason' => in_array($conditionId, $cycleIds, true) 'reason' => in_array($conditionId, $cycleIds, true)
? 'Condition dependency cycle detected.' ? 'Condition dependency cycle detected.'
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')), : ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
'causes' => $causes,
]; ];
} }
@@ -1130,6 +1202,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace; $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); $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
$items = []; $items = [];
@@ -1149,25 +1227,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$active = isset($activeIds[$taskId]); $active = isset($activeIds[$taskId]);
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value); $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); $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] ?? []); $taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
$items[] = [ $items[] = [
'kind' => 'task',
'id' => $taskId, 'id' => $taskId,
'node_id' => 'task:' . $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'] ?? ''), 'description' => (string)($task['description'] ?? ''),
'active' => $active, 'active' => $active,
'state' => $active ? 'active' : 'blocked', 'state' => $active ? 'active' : 'blocked',
'gate_type' => $gateType, 'gate_type' => $gateType,
'gate_ref_id' => $gateRefId, 'gate_ref_id' => $gateRefId,
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId), 'gate_ref_label' => $gateRefLabel,
'gate_satisfied' => ($trace['satisfied'] ?? false) === true, 'gate_satisfied' => $gateSatisfied,
'services' => $services, 'services' => $services,
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null), 'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'], 'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
'attachments' => $taskAttachments, 'attachments' => $taskAttachments,
'relay_bindings' => $bindings, 'relay_bindings' => $bindings,
'order_priority' => (int)($task['order_priority'] ?? 0), '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 +1297,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true); $modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
$scopeMatches = true; $scopeMatches = true;
$scopeReason = null; $scopeReason = null;
$scopeCause = null;
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
$scopeMatches = false; $scopeMatches = false;
$scopeReason = 'Action department scope does not match the simulated lane.'; $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) { } elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
$scopeMatches = false; $scopeMatches = false;
$scopeReason = 'Action lane scope does not match the simulated lane.'; $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)) { } elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
$scopeMatches = false; $scopeMatches = false;
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.'; $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)) { } elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
$scopeMatches = false; $scopeMatches = false;
$scopeReason = 'Action machine type scope does not match the simulated lane.'; $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); $conditionId = $this->nullableInt($action['condition_id'] ?? null);
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true); $conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
$enabled = (bool)($action['enabled'] ?? true); $enabled = (bool)($action['enabled'] ?? true);
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied; $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.'; $reason = 'Action would run for this simulator event.';
if (!$enabled) { if (!$enabled) {
$reason = 'Action is disabled.'; $reason = 'Action is disabled.';
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
} elseif (!$modeMatches) { } elseif (!$modeMatches) {
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.'; $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) { } elseif (!$scopeMatches) {
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.'; $reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
if ($scopeCause !== null) {
$causes[] = $scopeCause;
}
} elseif (!$conditionSatisfied) { } 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[] = [ $items[] = [
'kind' => 'action',
'id' => $actionId, 'id' => $actionId,
'node_id' => 'action:' . $actionId, 'node_id' => 'action:' . $actionId,
'label' => (string)$action['name'], 'node_ids' => ['action:' . $actionId],
'label' => $label,
'active' => $active, 'active' => $active,
'state' => $active ? 'active' : 'skipped', 'state' => $active ? 'active' : 'skipped',
'reason' => $reason, 'reason' => $reason,
'causes' => $causes,
'event' => (string)$action['event'], 'event' => (string)$action['event'],
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']), 'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
'wash_mode' => (string)$action['wash_mode'], 'wash_mode' => (string)$action['wash_mode'],
@@ -1257,7 +1359,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']), 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
'relay_state' => $action['relay_state'], 'relay_state' => $action['relay_state'],
'condition_id' => $conditionId, 'condition_id' => $conditionId,
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId), 'condition' => $conditionLabel,
'condition_satisfied' => $conditionSatisfied, 'condition_satisfied' => $conditionSatisfied,
'scope' => [ 'scope' => [
'department' => (int)$action['department'], 'department' => (int)$action['department'],
@@ -1758,7 +1860,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
string $predictedStatus, string $predictedStatus,
?string $reason ?string $reason
): array { ): 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 [ return [
'kind' => 'signal',
'id' => $id,
'node_id' => $id,
'node_ids' => array_values(array_unique($nodeIds)),
'label' => $label,
'state' => $predictedStatus,
'sequence' => $sequence, 'sequence' => $sequence,
'runtime_stage' => $runtimeStage, 'runtime_stage' => $runtimeStage,
'signal_type' => $signalType, 'signal_type' => $signalType,
@@ -1779,6 +1904,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'predicted_status' => $predictedStatus, 'predicted_status' => $predictedStatus,
'skip_block_reason' => $reason, 'skip_block_reason' => $reason,
'reason' => $reason, 'reason' => $reason,
'causes' => $causes,
]; ];
} }
@@ -2085,6 +2211,316 @@ 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
{
return in_array(
'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true
);
}
protected function dynamicImageButtonSequenceForTask(array $task): array
{
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
if (!$this->taskUsesProgramPicker($task)) {
return $buttons;
}
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons));
}
/**
* @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>> $conditions
* @param array<int,array<string,mixed>> $rules * @param array<int,array<string,mixed>> $rules
@@ -2213,6 +2649,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $failed; 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; return null;
} }
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
enum selfserve_lane_services enum selfserve_lane_services
{ {
case MACHINE; // Relay that controls the machine power 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: Notes:
- Route parameter name is `condition_id`. That value is used as the task gate id. - 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. - If an active task does not expose `MACHINE`, the relay will not be enabled.
Typical failures: Typical failures:
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
| Method | Required params | Permissions | Notes | | 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. | | `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/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` | Writes allowed service names to the lane cache. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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/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/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. | | `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. - 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. - 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: Typical failures:
- `400` invalid parameters - `400` invalid parameters
@@ -480,7 +486,7 @@ Important enums:
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE` - `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED` - `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states - `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 ### Machine Type And Wash Session Objects
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
if (is_string($btn)) { if (is_string($btn)) {
$trimmed = trim($btn); $trimmed = trim($btn);
$specialButton = strtolower($trimmed); $specialButton = strtolower($trimmed);
if ($specialButton === 'reset' || $specialButton === 'start') { if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
$ids[] = $specialButton; $ids[] = $specialButton;
continue; continue;
} }
+239 -5
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations description: License plate scanning operations
- name: Config - name: Config
description: Module configuration management description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding - name: Branding
description: Branding options management description: Branding options management
- name: Roles - name: Roles
@@ -4071,13 +4073,15 @@ paths:
- name: buttons - name: buttons
in: query in: query
required: false 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: schema:
oneOf: oneOf:
- type: string - type: string
- type: array - type: array
items: items:
type: integer oneOf:
- type: integer
- type: string
- name: current_step - name: current_step
in: query in: query
required: false required: false
@@ -6186,7 +6190,7 @@ paths:
reference: {type: string} reference: {type: string}
po: {type: string} po: {type: string}
pickup: {type: boolean} pickup: {type: boolean}
order_id: {type: integer} order_id: {type: integer, nullable: true}
items: items:
type: array type: array
items: items:
@@ -9295,7 +9299,10 @@ paths:
description: | description: |
Send a command (e.g., start, stop, reset) to a self-serve lane. 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 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 operationId: sendSelfServeLaneCommand
requestBody: requestBody:
required: true required: true
@@ -9353,6 +9360,9 @@ paths:
Updates the set of services that are allowed to be manually activated for a given self-serve lane, 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. 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. 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 operationId: setSelfServeLaneAllowedServices
requestBody: requestBody:
required: true required: true
@@ -9675,7 +9685,9 @@ paths:
description: | description: |
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services 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 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 operationId: enableSelfServeLaneMachineRelay
requestBody: requestBody:
required: true required: true
@@ -12830,6 +12842,189 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $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: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -17958,6 +18153,45 @@ components:
items: items:
type: object type: object
additionalProperties: true 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: hardware:
type: object type: object
additionalProperties: true additionalProperties: true
@@ -135,7 +135,7 @@ class departmentLanesRoute
* Query parameters: * Query parameters:
* - department (int, required) * - department (int, required)
* - lane (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 * - current_step (int >= 0, optional) → current click/step indicator
* - only_current_step (bool/int, optional) → if true, only draw current step highlight * - 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 * - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
@@ -30,6 +30,8 @@ class moduleSelfServeRoute
{ {
use route_t; use route_t;
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
public function run(): void public function run(): void
{ {
global /** @var response $response */ global /** @var response $response */
@@ -389,7 +391,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Command */ /** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () { $this->post('/modules/self-serve/lane/command', function () {
global $response; global $response;
self::requirePermission('modules_selfserve_lane_command_execute');
$selfserve = new selfserve(); $selfserve = new selfserve();
// Get the request user // Get the request user
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
@@ -412,35 +413,63 @@ class moduleSelfServeRoute
if ($command === null) { if ($command === null) {
$response->error("Invalid command: " . $commandParam); $response->error("Invalid command: " . $commandParam);
} }
$customer_number = $this->resolveEffectiveCustomerNumber();
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
// Require permissions for specific commands // Require permissions for specific commands
switch ($command) { switch ($command) {
case selfserve_lane_command::START: case selfserve_lane_command::START:
self::requirePermission('modules_selfserve_lane_command_execute_start'); $this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_start',
true
);
break; break;
case selfserve_lane_command::STOP: 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; break;
case selfserve_lane_command::RESERVE: 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; break;
case selfserve_lane_command::RELEASE: 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; break;
case selfserve_lane_command::RESET: 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; break;
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE: case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
$this->requirePropertyGateCommandPermission( $this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_access_gate', 'modules_selfserve_lane_command_execute_open_property_access_gate',
$lane, $lane,
(int)$user->customer_number->value() $customer_number
); );
break; break;
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
$this->requirePropertyGateCommandPermission( $this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_exit_gate', 'modules_selfserve_lane_command_execute_open_property_exit_gate',
$lane, $lane,
(int)$user->customer_number->value() $customer_number
); );
break; break;
} }
@@ -450,7 +479,7 @@ class moduleSelfServeRoute
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments(); $args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([ $args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters ...$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
]); ]);
$lane->execute($command, $args); $lane->execute($command, $args);
$response->success([ $response->success([
@@ -493,7 +522,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */ /** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () { $this->post('/modules/self-serve/lane/services/allowed', function () {
global $response; global $response;
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$selfserve = new selfserve(); $selfserve = new selfserve();
// Validate parameters // Validate parameters
self::requireParameters(['lane_id']); self::requireParameters(['lane_id']);
@@ -516,6 +544,12 @@ class moduleSelfServeRoute
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param))); $task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
// Build allowed services from provided tasks // Build allowed services from provided tasks
$lane = $selfserve->lane($lane_id); $lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
$this->requireSelfServeLaneAccess(
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_services_set_allowed']
);
$allowed_services = []; $allowed_services = [];
foreach ($task_ids as $tid) { foreach ($task_ids as $tid) {
if ($tid <= 0) continue; if ($tid <= 0) continue;
@@ -835,7 +869,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */ /** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () { $this->post('/modules/self-serve/lane/relay/machine/enable', function () {
global $response; global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$selfserve = new selfserve(); $selfserve = new selfserve();
// Validate parameters // Validate parameters
self::requireParameters(['lane_id']); self::requireParameters(['lane_id']);
@@ -848,6 +881,13 @@ class moduleSelfServeRoute
self::requireMinValue($duration, 1); self::requireMinValue($duration, 1);
} }
$lane = $selfserve->lane($lane_id); $lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
$this->requireSelfServeLaneAccess(
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_relay_enable_machine'],
true
);
try { try {
$this->applyShellyTransportOverride($lane); $this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
@@ -1271,9 +1311,87 @@ class moduleSelfServeRoute
$lane->setShellyTransportOverride($transport); $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 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; return;
} }
@@ -1281,21 +1399,70 @@ class moduleSelfServeRoute
return; 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 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; return false;
} }
$department_id = (int)$lane->department_lane?->department?->value(); $department_id = $this->departmentIdForLane($lane);
if ($department_id <= 0) { if ($department_id <= 0) {
return false; 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 protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
+6 -14
View File
@@ -4,28 +4,20 @@ namespace routes;
use traits\route_t; use traits\route_t;
require_once dirname(__DIR__) . '/classes/cors_policy.php';
class optionsRoute class optionsRoute
{ {
use route_t; use route_t;
public function run(): void public function run(): void
{ {
// When the OPTIONS method is requested, accept all using regex
$this->options('/.*', function () { $this->options('/.*', function () {
global $CORS; global $CORS;
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*'))); \classes\cors_policy::emitHeaders($preflight['headers']);
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) { http_response_code($preflight['status']);
header("Access-Control-Allow-Origin: " . ($origin ?: '*')); echo $preflight['body'];
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']);
}
}); });
} }
} }
@@ -12,6 +12,7 @@ use objects\departments_o;
use objects\logs_o; use objects\logs_o;
use objects\order_bookings_o; use objects\order_bookings_o;
use objects\order_items_o; use objects\order_items_o;
use objects\orders_o;
use objects\products_o; use objects\products_o;
use objects\users_o; use objects\users_o;
use traits\route_t; use traits\route_t;
@@ -265,6 +266,7 @@ class orderBookingRoute
$po = self::getTargetPo(false); // String | Null $po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null $pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects $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 $order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */ /** Authentication */
$auth = new authentication(); $auth = new authentication();
@@ -293,6 +295,7 @@ class orderBookingRoute
/** /**
* Update the object * Update the object
*/ */
$previous_order_id = (int)($object->order_id->value() ?? 0);
$data = [ $data = [
...(self::hasPermission($permission_other) && !empty($customer_number) ? [ ...(self::hasPermission($permission_other) && !empty($customer_number) ? [
'customer_number' => (int)$customer_number->customer_number->value(), 'customer_number' => (int)$customer_number->customer_number->value(),
@@ -307,9 +310,12 @@ class orderBookingRoute
...(isset($po) ? ['po' => $po] : []), ...(isset($po) ? ['po' => $po] : []),
...(isset($pickup) ? ['pickup' => $pickup] : []), ...(isset($pickup) ? ['pickup' => $pickup] : []),
...(isset($items) ? ['items' => $items] : []), ...(isset($items) ? ['items' => $items] : []),
...(isset($order_id) ? ['order_id' => $order_id] : []), ...($order_id_was_set ? ['order_id' => $order_id] : []),
]; ];
$object->update($data); $object->update($data);
if ($order_id_was_set && $order_id === null) {
self::clearMatchingOrderBookingLink($object, $previous_order_id);
}
/** /**
* Return the object * Return the object
*/ */
@@ -529,7 +535,7 @@ class orderBookingRoute
return $value; return $value;
} }
private function getTargetOrderId(bool $required = true): int|string|null private function getTargetOrderId(bool $required = true): int|null
{ {
global $response; global $response;
$parameter = 'order_id'; $parameter = 'order_id';
@@ -541,7 +547,7 @@ class orderBookingRoute
} else { } else {
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]); self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
// Check if the value is 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::requireMinLength($parameter, 1);
self::requireMaxLength($parameter, 9); self::requireMaxLength($parameter, 9);
@@ -551,6 +557,28 @@ class orderBookingRoute
return $value; 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 private function getTargetCustomer(bool $required = true): users_o|null
{ {
global $response; global $response;
@@ -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 () { $this->get('/superuser/releases', function () {
global $response; global $response;
$this->requirePermission('superuser_release_manager_view'); $this->requirePermission('superuser_release_manager_view');
@@ -61,6 +76,34 @@ class releaseManagerRoute
'superuser_release_manager_view' => 'View Release Manager source configuration', '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 () { $this->post('/superuser/releases/config', function () {
global $response; global $response;
$this->requirePermission('superuser_release_manager_manage'); $this->requirePermission('superuser_release_manager_manage');
@@ -161,6 +204,18 @@ class releaseManagerRoute
'superuser_release_manager_rollback' => 'Rollback an active release channel', '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 () { $this->post('/superuser/releases/channels/{id}/bundle', function () {
global $response; global $response;
$this->requirePermission('superuser_release_manager_deploy'); $this->requirePermission('superuser_release_manager_deploy');
@@ -380,6 +435,14 @@ class releaseManagerRoute
'superuser_release_manager_deploy' => 'Promote a deployment to its release channel', '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 () { $this->post('/superuser/releases/replay-targets', function () {
global $response; global $response;
$this->requirePermission('superuser_release_manager_replay'); $this->requirePermission('superuser_release_manager_replay');
@@ -451,4 +514,20 @@ class releaseManagerRoute
return $payload; 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']
?? ''
));
}
} }
@@ -134,6 +134,7 @@ class workerRoute
'host' => gethostname(), 'host' => gethostname(),
'version' => '1.0.1', 'version' => '1.0.1',
'routes' => $router->countRoutes(), 'routes' => $router->countRoutes(),
'test' => 'Beta tests',
'redis' => [ 'redis' => [
'host' => $REDIS_CONFIG['host'], 'host' => $REDIS_CONFIG['host'],
'user' => $REDIS_CONFIG['user'], 'user' => $REDIS_CONFIG['user'],
@@ -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']);
});
@@ -38,8 +38,9 @@ namespace {
expect($content)->toContain("switch (\$dynamic_image_id)"); expect($content)->toContain("switch (\$dynamic_image_id)");
}); });
it('accepts ordered dynamic image button tokens including reset start and zero', function (): void { it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([ expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([
'program_picker',
'reset', 'reset',
0, 0,
2, 2,
@@ -50,7 +51,7 @@ namespace {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php')); $route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse(); expect($route)->not->toBeFalse();
expect($route)->toContain('ordered highlighted button IDs'); 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 { 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('$deferredStartButtons = $this->drawHighlightedButtonSequence();');
expect($content)->toContain('getOrderedHighlightedButtonTokens'); expect($content)->toContain('getOrderedHighlightedButtonTokens');
expect($content)->toContain('normalizeHighlightedButtonToken'); 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('drawDeferredHighlightedButtons($deferredStartButtons)');
expect($content)->toContain('self::BUTTON_PROGRAM_PICKER');
$thumbOffset = strpos($content, 'public function drawStepThumb'); $setupOffset = strpos($content, 'public function setup');
expect($thumbOffset)->not->toBeFalse(); expect($setupOffset)->not->toBeFalse();
$thumbBody = substr($content, (int)$thumbOffset, 1800); $setupBody = substr($content, (int)$setupOffset, 1000);
expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE'); expect($setupBody)->not->toContain('drawStepThumb();');
expect($thumbBody)->not->toContain('drawStepCounterOnButton');
expect($thumbBody)->not->toContain('only_generate_current_step');
}); });
} }
@@ -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 = [ $files = [
app_path('index.php'), app_path('classes/cors_policy.php'),
app_path('modules/washcertificates/index.php'), app_path('modules/washcertificates/index.php'),
app_path('routes/optionsRoute.php'),
]; ];
foreach ($files as $file) { 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($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,59 @@ it('marks a channel ready when all release services are healthy', function (): v
->and($channel['services'])->toHaveCount(5); ->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('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void {
$overview = releaseStatusOverviewForTest([ $overview = releaseStatusOverviewForTest([
'channels' => [ 'channels' => [
[ [
@@ -150,11 +202,13 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
$channel = releaseStatusChannelBySlug($overview, 'beta'); $channel = releaseStatusChannelBySlug($overview, 'beta');
$missingLabels = array_column($channel['missing_values'], 'label'); $missingLabels = array_column($channel['missing_values'], 'label');
$missingKeys = array_column($channel['missing_values'], 'key');
expect($overview['state'])->toBe('blocked') expect($overview['state'])->toBe('blocked')
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(5) ->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4)
->and($channel['readiness'])->toBe('blocked') ->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 version')
->and($missingLabels)->toContain('frontend URL') ->and($missingLabels)->toContain('frontend URL')
->and($missingLabels)->toContain('API version') ->and($missingLabels)->toContain('API version')
@@ -166,6 +220,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 { it('surfaces failed latest deployments as critical promotion blockers', function (): void {
$overview = releaseStatusOverviewForTest([ $overview = releaseStatusOverviewForTest([
'channels' => [ 'channels' => [
@@ -232,6 +430,73 @@ it('surfaces failed latest deployments as critical promotion blockers', function
'type' => 'failed_deployment', 'type' => 'failed_deployment',
'deployment_id' => 57, 'deployment_id' => 57,
'next_action' => 'Open the deployment logs and fix composer dependencies.', '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 +554,8 @@ it('flags isolated stacks that are missing database Redis and MinIO services', f
'severity' => 'critical', 'severity' => 'critical',
'issue_type' => 'missing_value', 'issue_type' => 'missing_value',
'missing_key' => 'minio_service', '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 { it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void {
@@ -346,5 +612,8 @@ it('maps degraded Coolify targets and reconcile failures to release service issu
'severity' => 'critical', 'severity' => 'critical',
'type' => 'service_unhealthy', 'type' => 'service_unhealthy',
'service_key' => 'database', '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,29 @@ it('verifies GitHub sha256 webhook signatures', function (): void {
expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse(); 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 GitHub repository identifiers for private repository access checks', function (): void { 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('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue'); expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
@@ -250,6 +273,7 @@ it('builds explicit Coolify application route labels for release API targets', f
->and($payload['force_domain_override'])->toBeTrue() ->and($payload['force_domain_override'])->toBeTrue()
->and($labels)->toContain('custom.keep=true') ->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.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.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.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'); ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
@@ -328,6 +352,204 @@ it('does not treat an existing Coolify service as an application just because a
], 'existing-application-uuid'))->toBe('application'); ], 'existing-application-uuid'))->toBe('application');
}); });
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('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('rejects beta release bundles that resolve to isolated cloned or fresh data services', function (): void {
$manager = new release_manager();
$assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
$assert->setAccessible(true);
$betaChannel = ['id' => 2, 'slug' => 'beta'];
expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))
->toThrow(RuntimeException::class, 'frontend and API targets');
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 except beta', function (): void { it('keeps release branch services out of the production Coolify environment except beta', function (): void {
$manager = new release_manager(); $manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
@@ -424,6 +646,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
$response = file_get_contents(app_path('classes/response.php')); $response = file_get_contents(app_path('classes/response.php'));
$status = file_get_contents(app_path('classes/superuser_system_status_service.php')); $status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
$index = file_get_contents(app_path('index.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_channels');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
@@ -433,6 +656,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets'); 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_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles'); 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_replay_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events'); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
@@ -443,9 +668,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', 'service_set_id'");
expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'"); expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'"); 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("ensureColumn('release_timeline_sessions', 'device_type'");
expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'"); expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'"); 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_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'"); expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
expect($schema)->toContain("['stable', 'Stable'"); expect($schema)->toContain("['stable', 'Stable'");
@@ -457,12 +686,18 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/release/runtime'); expect($route)->toContain('/release/runtime');
expect($route)->toContain('/release/timeline/events'); expect($route)->toContain('/release/timeline/events');
expect($route)->toContain('/release/github/webhook'); 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/config');
expect($route)->toContain('/superuser/releases/github/repositories'); expect($route)->toContain('/superuser/releases/github/repositories');
expect($route)->toContain('/superuser/releases/github/branches'); expect($route)->toContain('/superuser/releases/github/branches');
expect($route)->toContain('/superuser/releases/github/commits'); expect($route)->toContain('/superuser/releases/github/commits');
expect($route)->toContain('/superuser/releases/github/test'); expect($route)->toContain('/superuser/releases/github/test');
expect($route)->toContain('/superuser/releases/channels'); 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/channels/{id}/bundle');
expect($route)->toContain('/superuser/releases/assignment-subjects'); expect($route)->toContain('/superuser/releases/assignment-subjects');
expect($route)->toContain('/superuser/releases/assignments'); expect($route)->toContain('/superuser/releases/assignments');
@@ -473,6 +708,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}/deploy');
expect($route)->toContain('/superuser/releases/bundles/{id}/promote'); expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
expect($route)->toContain('/superuser/releases/deployments'); 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/replay-targets');
expect($route)->toContain('/superuser/releases/timeline/sessions'); expect($route)->toContain('/superuser/releases/timeline/sessions');
expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}'); expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}');
@@ -484,6 +720,14 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain("requirePermission('superuser_release_manager_replay')"); expect($route)->toContain("requirePermission('superuser_release_manager_replay')");
expect($manager)->toContain('verifyGithubSignature'); expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('verifyReleaseGateToken');
expect($manager)->toContain('normalizeReleaseGateInput');
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('normalizeGithubRepositoryName');
expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'"); expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'");
expect($manager)->toContain('releaseConfig'); expect($manager)->toContain('releaseConfig');
@@ -512,6 +756,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('searchAssignmentSubjects'); expect($manager)->toContain('searchAssignmentSubjects');
expect($manager)->toContain('publicAssignmentSubjectSuggestion'); expect($manager)->toContain('publicAssignmentSubjectSuggestion');
expect($manager)->toContain('available_channels'); expect($manager)->toContain('available_channels');
expect($manager)->toContain("'source' => 'deployment'");
expect($manager)->toContain('chooseRuntimeChannel'); expect($manager)->toContain('chooseRuntimeChannel');
expect($manager)->toContain('requestedRuntimeChannelSlug'); expect($manager)->toContain('requestedRuntimeChannelSlug');
expect($manager)->toContain("status = 'superseded'"); expect($manager)->toContain("status = 'superseded'");
@@ -531,6 +776,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain("'replica_failover' => false"); expect($manager)->toContain("'replica_failover' => false");
expect($manager)->toContain('deploymentCanBePromoted'); expect($manager)->toContain('deploymentCanBePromoted');
expect($manager)->toContain('deploymentFailureSummary'); 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('coolifyProjectSuggestions');
expect($manager)->toContain('releaseCoolifyServerUuid'); expect($manager)->toContain('releaseCoolifyServerUuid');
expect($manager)->toContain('coolify_project_uuid'); expect($manager)->toContain('coolify_project_uuid');
@@ -564,6 +812,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('coolify_enable_ssl'); expect($manager)->toContain('coolify_enable_ssl');
expect($manager)->toContain('createService'); expect($manager)->toContain('createService');
expect($manager)->toContain('updateService'); 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('channel_presets');
expect($manager)->toContain('target_presets'); expect($manager)->toContain('target_presets');
@@ -572,10 +821,20 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($status)->toContain("'key' => 'releasemanager'"); expect($status)->toContain("'key' => 'releasemanager'");
expect($status)->toContain('probeReleaseManagerModule'); expect($status)->toContain('probeReleaseManagerModule');
expect($index)->toContain('release_manager::initializeRequestContext'); 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('X-Release-Channel');
expect($manager)->toContain('normalizeReleaseApiIngressPath'); 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('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 { it('captures release request context from headers and runtime query parameters', function (): void {
@@ -657,75 +916,78 @@ it('normalizes channel-prefixed API ingress paths before route dispatch', functi
} }
}); });
it('allows explicit runtime selection of any enabled release channel', function (): void { it('maps the public master API prefix to the stable release channel', function (): void {
$db = $GLOBALS['db'] ?? null; $server = $_SERVER;
$get = $_GET;
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
try { try {
$GLOBALS['db'] = new class { $_SERVER['REQUEST_URI'] = '/master/api/release/runtime';
public function prepare(string $sql): object $_GET = [];
{ $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
return new class { 'trace_id' => 'trace-master',
/** @var array<int,mixed> */ 'requested_channel' => '',
private array $params = []; '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 $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']);
{
$this->params = $params; 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 {
$_SERVER = $server;
$_GET = $get;
if ($context === null) {
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
} else {
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
} }
public function execute(): void
{
} }
});
public function get_result(): object it('uses master as the public route slug for the stable release channel', function (): void {
{ expect(release_manager::routeSlugForChannel('stable'))->toBe('master');
$slug = (string)($this->params[0] ?? ''); expect(release_manager::routeSlugForChannel('beta'))->toBe('beta');
expect(release_manager::channelSlugForRoute('master'))->toBe('stable');
expect(release_manager::channelSlugForRoute('internal'))->toBe('internal');
});
return new class($slug) { it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
public function __construct(private readonly string $slug) $manager = new release_manager();
{ $chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
} $chooseRuntimeChannel->setAccessible(true);
public function fetch_all(int $mode): array $stable = [
{ 'id' => 1,
if ($this->slug !== 'internal') { 'slug' => 'stable',
return []; 'enabled' => 1,
} 'default_channel' => 1,
];
return [[ $internal = [
'id' => 4, 'id' => 4,
'slug' => 'internal', 'slug' => 'internal',
'name' => 'Internal', 'name' => 'Internal',
'enabled' => 1, 'enabled' => 1,
'default_channel' => 0, 'default_channel' => 0,
'deleted_at' => null, ];
]];
}
};
}
};
}
};
$manager = new release_manager(); expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable);
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal);
$chooseRuntimeChannel->setAccessible(true);
$channel = $chooseRuntimeChannel->invoke($manager, [
'id' => 1,
'slug' => 'stable',
'enabled' => 1,
'default_channel' => 1,
], [], 'internal');
expect($channel['slug'])->toBe('internal');
} finally {
if ($db === null) {
unset($GLOBALS['db']);
} else {
$GLOBALS['db'] = $db;
}
}
}); });
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void { it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
@@ -830,6 +1092,7 @@ it('requires non-default release channel runtime URLs and preserves load balance
'letsencrypt' '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.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.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'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
@@ -842,11 +1105,103 @@ it('requires non-default release channel runtime URLs and preserves load balance
expect($methodSource)->toContain('frontend_base_url'); expect($methodSource)->toContain('frontend_base_url');
expect($methodSource)->toContain('api_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('frontend_version');
expect($methodSource)->toContain('api_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 { it('exposes release version git commit metadata for runtime channel cards', function (): void {
$manager = new release_manager(); $manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
@@ -882,8 +1237,7 @@ it('exposes release version git commit metadata for runtime channel cards', func
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); 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 { it('only chooses requested runtime channels from channels available to the principal', function (): void {
$db = $GLOBALS['db'] ?? null;
$manager = new release_manager(); $manager = new release_manager();
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$choose->setAccessible(true); $choose->setAccessible(true);
@@ -900,43 +1254,20 @@ it('chooses requested runtime channels from available channels or enabled channe
'name' => 'Canary', 'name' => 'Canary',
'default_channel' => 0, 'default_channel' => 0,
]; ];
$internal = [
try { 'id' => 3,
$GLOBALS['db'] = new class { 'slug' => 'internal',
public function prepare(string $sql): object 'name' => 'Internal',
{ 'default_channel' => 0,
return new class { 'enabled' => 1,
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], '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, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary); expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
} finally { expect($internal['enabled'])->toBe(1);
if ($db === null) {
unset($GLOBALS['db']);
} else {
$GLOBALS['db'] = $db;
}
}
}); });
it('normalizes release assignment subject suggestions without leaking private fields', function (): void { 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('shouldAdvanceActiveProvisionDuringRefresh');
expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])'); 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('stale targets do not keep a healthy current target below 100%');
expect($manager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket"); $normalizedManager = str_replace("\r\n", "\n", $manager);
expect($manager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket"); 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('private function minioBucketStats(');
expect($manager)->toContain("'minio' => ["); expect($manager)->toContain("'minio' => [");
expect($routes)->toContain("/superuser/replication/minio"); expect($routes)->toContain("/superuser/replication/minio");
@@ -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" "compose_up_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_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($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
expect($composeSource)->toContain('version: "2.4"'); $normalizedComposeSource = str_replace("\r\n", "\n", $composeSource);
expect($composeSource)->toContain('condition: service_healthy'); expect($normalizedComposeSource)->toContain('version: "2.4"');
expect($composeSource)->toContain("minio:\n condition: service_started"); expect($normalizedComposeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("mariadb:\n condition: service_started"); 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('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]'); expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_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,
],
]);
});
@@ -13,14 +13,29 @@ use routes\moduleSelfServeRoute;
class SelfservePropertyGatePermissionBypassRouteHarness extends moduleSelfServeRoute class SelfservePropertyGatePermissionBypassRouteHarness extends moduleSelfServeRoute
{ {
public bool $activeWashResult = false; 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}> */ /** @var array<int,array{department_id:int,customer_number:int}> */
public array $activeWashChecks = []; 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 public function canUsePropertyGate(selfserve_lane $lane, int $customer_number): bool
{ {
return $this->canCustomerUsePropertyGateForLane($lane, $customer_number); 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 protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
{ {
$this->activeWashChecks[] = [ $this->activeWashChecks[] = [
@@ -81,6 +96,16 @@ it('does not bypass property gate permissions without a positive customer number
expect($route->activeWashChecks)->toBe([]); 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 { it('does not bypass property gate permissions when the customer has no active wash in the target department', function (): void {
$route = new SelfservePropertyGatePermissionBypassRouteHarness(); $route = new SelfservePropertyGatePermissionBypassRouteHarness();
$route->activeWashResult = false; $route->activeWashResult = false;
@@ -623,12 +623,266 @@ it('builds guided simulator debug payload with blockers and canvas annotations',
->and($debug['questions'][0]['state'])->toBe('missing') ->and($debug['questions'][0]['state'])->toBe('missing')
->and($debug['questions'][0]['answer_source'])->toBe('override') ->and($debug['questions'][0]['answer_source'])->toBe('override')
->and($debug['tasks'][0]['state'])->toBe('blocked') ->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('Answer required questions')
->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay') ->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']['question:11']['state'])->toBe('warning')
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error'); ->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
}); });
it('adds program picker before mapped machine buttons 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' => [], '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' => [], '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 { it('projects visible question answer paths into grouped task service and signal outcomes', function (): void {
$service = selfserve_studio_graph_without_constructor(); $service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array { $simulate = function (array $overrides): array {
+5 -1
View File
@@ -28,7 +28,7 @@ http:
- main: api.truckwash.dk - main: api.truckwash.dk
api-preflight-io: 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] entryPoints: [websecure, websecure-staging]
middlewares: [secure-headers] middlewares: [secure-headers]
service: noop@internal service: noop@internal
@@ -37,6 +37,7 @@ http:
certResolver: le_io certResolver: le_io
domains: domains:
- main: api.truckwash.io - main: api.truckwash.io
- main: api-v2.truckwash.io
cloud-preflight: cloud-preflight:
rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`) rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`)
@@ -82,6 +83,7 @@ http:
- "https://www.truckwash.io" - "https://www.truckwash.io"
- "https://api.truckwash.io" - "https://api.truckwash.io"
- "https://api.truckwash.io:4433" - "https://api.truckwash.io:4433"
- "https://api-v2.truckwash.io"
- "https://web.truckwash.dk" - "https://web.truckwash.dk"
- "https://api.truckwash.dk" - "https://api.truckwash.dk"
- "https://truckwash.dk" - "https://truckwash.dk"
@@ -107,6 +109,8 @@ http:
- X-Release-Trace - X-Release-Trace
- X-Release-Channel - X-Release-Channel
- X-Frontend-Version - X-Frontend-Version
- Cache-Control
- Pragma
api-ratelimit: api-ratelimit:
rateLimit: rateLimit:
average: 100 average: 100