Integrate cors_policy class to standardize CORS handling, refactor optionsRoute to use it, and add unit tests for CORS and Self-Serve Lane Access functionalities.
This commit is contained in:
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
|
||||
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
|
||||
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
|
||||
|
||||
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
|
||||
# Any blank debug value falls back to the live value above.
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ $CONFIG_DB = [
|
||||
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
|
||||
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
|
||||
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
|
||||
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
|
||||
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
|
||||
$ECONOMIC_API = [
|
||||
'app_access_grant' => '', // Economic API access grant token (1)
|
||||
'app_access_grant2' => '', // Economic API access grant token (2)
|
||||
|
||||
+10
-1
@@ -8957,6 +8957,10 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
|
||||
department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9007,6 +9011,9 @@ paths:
|
||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||
confirming a wash start.
|
||||
operationId: setSelfServeLaneAllowedServices
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9450,7 +9457,9 @@ paths:
|
||||
description: |
|
||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||
enabled; an explicit call to this endpoint is required.
|
||||
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
|
||||
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
|
||||
operationId: enableSelfServeLaneMachineRelay
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -100,6 +100,28 @@ class coolify_api_client
|
||||
}
|
||||
|
||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ($env as $key => $value) {
|
||||
@@ -113,11 +135,7 @@ class coolify_api_client
|
||||
];
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deployResource(string $uuid, bool $force = false): array
|
||||
|
||||
@@ -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 !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use customers\economicCustomers;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/cors_policy.php';
|
||||
|
||||
class release_manager
|
||||
{
|
||||
private const APPS = ['frontend', 'api'];
|
||||
@@ -29,6 +31,65 @@ class release_manager
|
||||
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod', 'beta'];
|
||||
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
|
||||
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
||||
private const RELEASE_API_RUNTIME_ENV_KEYS = [
|
||||
'USE_ENV',
|
||||
'DEBUG',
|
||||
'ENCRYPTION_KEY',
|
||||
'CORS',
|
||||
'CONFIG_TIMEZONE',
|
||||
'CONFIG_DB_TARGET',
|
||||
'CONFIG_DB_HOST',
|
||||
'CONFIG_DB_USER',
|
||||
'CONFIG_DB_PASSWORD',
|
||||
'CONFIG_DB_DATABASE',
|
||||
'CONFIG_DB_PORT',
|
||||
'CONFIG_DB_SSL_MODE',
|
||||
'CONFIG_DB_DEBUG_HOST',
|
||||
'CONFIG_DB_DEBUG_USER',
|
||||
'CONFIG_DB_DEBUG_PASSWORD',
|
||||
'CONFIG_DB_DEBUG_DATABASE',
|
||||
'CONFIG_DB_DEBUG_PORT',
|
||||
'CONFIG_DB_DEBUG_SSL_MODE',
|
||||
'REDIS_CONFIG_HOST',
|
||||
'REDIS_CONFIG_USER',
|
||||
'REDIS_CONFIG_PASSWORD',
|
||||
'REDIS_CONFIG_DATABASE',
|
||||
'REDIS_CONFIG_PORT',
|
||||
'REDIS_CONFIG_DEBUG_HOST',
|
||||
'REDIS_CONFIG_DEBUG_USER',
|
||||
'REDIS_CONFIG_DEBUG_PASSWORD',
|
||||
'REDIS_CONFIG_DEBUG_DATABASE',
|
||||
'REDIS_CONFIG_DEBUG_PORT',
|
||||
'ECONOMIC_API_APP_ACCESS_GRANT',
|
||||
'ECONOMIC_API_APP_ACCESS_GRANT2',
|
||||
'ECONOMIC_API_APP_SECRET_TOKEN',
|
||||
'WORDPRESS_STATIC_TOKEN',
|
||||
'EMAIL_WASH_CERTIFICATE_TOKEN',
|
||||
'WORDPRESS_API_URL',
|
||||
'MINIO_ENDPOINT',
|
||||
'MINIO_ACCESS_KEY',
|
||||
'MINIO_SECRET_KEY',
|
||||
'SLACK_DEFAULT_WEBHOOK',
|
||||
];
|
||||
private const RELEASE_API_RUNTIME_ENV_PREFIXES = [
|
||||
'EDGE_',
|
||||
'RELEASE_MANAGER_',
|
||||
'COOLIFY_',
|
||||
'HETZNER_',
|
||||
'OPENAI_',
|
||||
'STRIPE_',
|
||||
'FXRATES_',
|
||||
'WEATHER_',
|
||||
'MOTOR_',
|
||||
'BIRD_',
|
||||
'OCR_',
|
||||
'LICENSE_',
|
||||
'VIRK_',
|
||||
'LIMBLE_',
|
||||
'ENTRA_',
|
||||
'REQUEST_QUEUE_',
|
||||
'WORKFEED_',
|
||||
];
|
||||
private const SUBJECT_TYPES = ['user', 'subuser', 'customer'];
|
||||
private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full'];
|
||||
private const MODULE_KEYS = [
|
||||
@@ -792,7 +853,7 @@ class release_manager
|
||||
'api_ping_paths' => $this->releaseGateStringArray(
|
||||
$input['api_ping_paths']
|
||||
?? $input['api_paths']
|
||||
?? ['/ping', '/master/api/ping', '/canary/api/ping', '/stable/api/ping']
|
||||
?? ['/master/api/ping']
|
||||
),
|
||||
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']),
|
||||
];
|
||||
@@ -1083,7 +1144,7 @@ class release_manager
|
||||
'status' => 'failed',
|
||||
'message' => 'api-v2 gateway or channel API health failed.',
|
||||
'diagnostic' => $throwable->getMessage(),
|
||||
'solution_hint' => 'Repair api-v2 routing so /ping, /master/api/ping, and channel /api/ping endpoints return 200 JSON before frontend promotion.',
|
||||
'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.',
|
||||
'context' => [
|
||||
'api_base_url' => $apiBaseUrl,
|
||||
'checked' => $checked,
|
||||
@@ -5063,6 +5124,7 @@ class release_manager
|
||||
$resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid);
|
||||
$created = null;
|
||||
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
|
||||
$runtimeEnvUpdate = null;
|
||||
|
||||
if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) {
|
||||
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
|
||||
@@ -5120,6 +5182,7 @@ class release_manager
|
||||
if ($applicationUpdate !== []) {
|
||||
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
|
||||
}
|
||||
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context);
|
||||
} elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
|
||||
if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) {
|
||||
throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.');
|
||||
@@ -5133,6 +5196,11 @@ class release_manager
|
||||
],
|
||||
'force_domain_override' => true,
|
||||
]);
|
||||
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context);
|
||||
}
|
||||
|
||||
if ($runtimeEnvUpdate === null) {
|
||||
$runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context);
|
||||
}
|
||||
|
||||
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
||||
@@ -5143,10 +5211,140 @@ class release_manager
|
||||
'public_url' => $publicUrl,
|
||||
'created' => self::redactPayload($created ?? []),
|
||||
'updated' => self::redactPayload($update ?? []),
|
||||
'runtime_env' => $runtimeEnvUpdate,
|
||||
'deployment' => self::redactPayload($deployment),
|
||||
];
|
||||
}
|
||||
|
||||
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
||||
{
|
||||
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
||||
if ($env === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($resourceType === 'application') {
|
||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||
} else {
|
||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||
}
|
||||
|
||||
return [
|
||||
'resource_type' => $resourceType,
|
||||
'count' => count($env),
|
||||
'keys' => array_keys($env),
|
||||
];
|
||||
}
|
||||
|
||||
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||
{
|
||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||
$env = $contextEnv;
|
||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||
if ($app !== 'api') {
|
||||
return $env;
|
||||
}
|
||||
|
||||
$env['USE_ENV'] = $env['USE_ENV'] ?? 'true';
|
||||
foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) {
|
||||
$this->appendRuntimeEnvValue($env, $key);
|
||||
}
|
||||
|
||||
$runtime = array_replace(
|
||||
is_array($_ENV ?? null) ? $_ENV : [],
|
||||
is_array($_SERVER ?? null) ? $_SERVER : [],
|
||||
is_array(getenv()) ? getenv() : []
|
||||
);
|
||||
foreach ($runtime as $key => $value) {
|
||||
$key = (string)$key;
|
||||
if (!$this->releaseRuntimeEnvKeyAllowed($key)) {
|
||||
continue;
|
||||
}
|
||||
$this->appendRuntimeEnvValue($env, $key, $value);
|
||||
}
|
||||
|
||||
$env = array_replace($env, $contextEnv);
|
||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||
}
|
||||
|
||||
private function releaseCoolifyContextEnv(array $context): array
|
||||
{
|
||||
$env = [];
|
||||
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
|
||||
if (is_array($context[$key] ?? null)) {
|
||||
foreach ($context[$key] as $envKey => $value) {
|
||||
$this->appendRuntimeEnvValue($env, (string)$envKey, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['coolify_env_file', 'env'] as $key) {
|
||||
$raw = $context[$key] ?? null;
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
continue;
|
||||
}
|
||||
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
|
||||
$line = trim((string)$line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$envKey, $value] = explode('=', $line, 2);
|
||||
$this->appendRuntimeEnvValue($env, trim($envKey), $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||
}
|
||||
|
||||
private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
|
||||
return;
|
||||
}
|
||||
if ($value === null) {
|
||||
$value = getenv($key);
|
||||
if ($value === false && array_key_exists($key, $_ENV ?? [])) {
|
||||
$value = $_ENV[$key];
|
||||
}
|
||||
if ($value === false && array_key_exists($key, $_SERVER ?? [])) {
|
||||
$value = $_SERVER[$key];
|
||||
}
|
||||
}
|
||||
if ($value === false || $value === null || is_array($value) || is_object($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$env[$key] = (string)$value;
|
||||
}
|
||||
|
||||
private function releaseRuntimeEnvKeyAllowed(string $key): bool
|
||||
{
|
||||
if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) {
|
||||
if (str_starts_with($key, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizeCoolifyRuntimeEnv(array $env): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($env as $key => $value) {
|
||||
$this->appendRuntimeEnvValue($normalized, (string)$key, $value);
|
||||
}
|
||||
ksort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array
|
||||
{
|
||||
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
|
||||
@@ -5305,6 +5503,7 @@ class release_manager
|
||||
$certResolver = trim((string)($certResolver ?? ''));
|
||||
$httpLabel = 'http-0-' . $resourceUuid;
|
||||
$httpsLabel = 'https-0-' . $resourceUuid;
|
||||
$priority = (string)(1000 + strlen($path));
|
||||
$labels = [
|
||||
'traefik.enable=true',
|
||||
'traefik.http.middlewares.gzip.compress=true',
|
||||
@@ -5314,6 +5513,7 @@ class release_manager
|
||||
if ($scheme === 'https') {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https";
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}";
|
||||
if ($routePort !== null) {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
||||
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
||||
@@ -5331,6 +5531,7 @@ class release_manager
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}";
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
|
||||
if ($routePort !== null) {
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
|
||||
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
|
||||
@@ -5339,6 +5540,7 @@ class release_manager
|
||||
} else {
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
|
||||
if ($routePort !== null) {
|
||||
$labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
|
||||
$labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
|
||||
|
||||
@@ -10,30 +10,18 @@ const WD = __DIR__;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/classes/cors_policy.php';
|
||||
|
||||
/** CORS */
|
||||
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
exit;
|
||||
}
|
||||
}
|
||||
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
|
||||
/** Debug */
|
||||
if ($DEBUG) {
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
|
||||
| Method | Required params | Permissions | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
|
||||
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
||||
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
||||
|
||||
@@ -382,6 +382,12 @@ STOP flow details:
|
||||
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
||||
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
||||
|
||||
Customer start-wash release checklist:
|
||||
|
||||
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
|
||||
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
|
||||
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
|
||||
|
||||
Typical failures:
|
||||
|
||||
- `400` invalid parameters
|
||||
|
||||
@@ -9299,7 +9299,10 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
|
||||
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
|
||||
department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9357,6 +9360,9 @@ paths:
|
||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||
confirming a wash start.
|
||||
operationId: setSelfServeLaneAllowedServices
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9679,7 +9685,9 @@ paths:
|
||||
description: |
|
||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||
enabled; an explicit call to this endpoint is required.
|
||||
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||
operationId: enableSelfServeLaneMachineRelay
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -30,6 +30,8 @@ class moduleSelfServeRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
@@ -389,7 +391,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Command */
|
||||
$this->post('/modules/self-serve/lane/command', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_command_execute');
|
||||
$selfserve = new selfserve();
|
||||
// Get the request user
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -412,35 +413,63 @@ class moduleSelfServeRoute
|
||||
if ($command === null) {
|
||||
$response->error("Invalid command: " . $commandParam);
|
||||
}
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
|
||||
// Require permissions for specific commands
|
||||
switch ($command) {
|
||||
case selfserve_lane_command::START:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_start');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_start',
|
||||
true
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::STOP:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_stop');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_stop',
|
||||
true,
|
||||
true
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RESERVE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_reserve',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RELEASE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_release');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_release',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RESET:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_reset');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_reset',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_access_gate',
|
||||
$lane,
|
||||
(int)$user->customer_number->value()
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_exit_gate',
|
||||
$lane,
|
||||
(int)$user->customer_number->value()
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -450,7 +479,7 @@ class moduleSelfServeRoute
|
||||
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
|
||||
$args->setParameters([
|
||||
...$this->getParametersAsArray(), // Pass all parameters
|
||||
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
|
||||
'customer_number' => $customer_number, // Get customer number from request user
|
||||
]);
|
||||
$lane->execute($command, $args);
|
||||
$response->success([
|
||||
@@ -493,7 +522,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
|
||||
$this->post('/modules/self-serve/lane/services/allowed', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
self::requireParameters(['lane_id']);
|
||||
@@ -516,6 +544,12 @@ class moduleSelfServeRoute
|
||||
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
|
||||
// Build allowed services from provided tasks
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
$this->requireSelfServeLaneAccess(
|
||||
$lane,
|
||||
$customer_number === null ? 0 : (int)$customer_number,
|
||||
['modules_selfserve_lane_services_set_allowed']
|
||||
);
|
||||
$allowed_services = [];
|
||||
foreach ($task_ids as $tid) {
|
||||
if ($tid <= 0) continue;
|
||||
@@ -835,7 +869,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
|
||||
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
self::requireParameters(['lane_id']);
|
||||
@@ -848,6 +881,13 @@ class moduleSelfServeRoute
|
||||
self::requireMinValue($duration, 1);
|
||||
}
|
||||
$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 {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
|
||||
@@ -1271,9 +1311,87 @@ class moduleSelfServeRoute
|
||||
$lane->setShellyTransportOverride($transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $permissions
|
||||
*/
|
||||
private function hasAllPermissions(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (!$this->hasPermission($permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $elevated_permissions
|
||||
*/
|
||||
private function requireSelfServeLaneAccess(
|
||||
selfserve_lane $lane,
|
||||
int $customer_number,
|
||||
array $elevated_permissions,
|
||||
bool $requires_active_wash = false,
|
||||
bool $requires_operational_lane = true
|
||||
): void {
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($requires_active_wash) {
|
||||
$customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||
&& (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
|
||||
} else {
|
||||
$customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
}
|
||||
|
||||
private function requireSelfServeLaneCommandPermission(
|
||||
selfserve_lane $lane,
|
||||
int $customer_number,
|
||||
string $command_permission,
|
||||
bool $allow_customer_self_serve,
|
||||
bool $requires_active_wash = false
|
||||
): void {
|
||||
$elevated_permissions = [
|
||||
'modules_selfserve_lane_command_execute',
|
||||
$command_permission,
|
||||
];
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($allow_customer_self_serve) {
|
||||
$customer_allowed = $requires_active_wash
|
||||
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->emitForbidden(
|
||||
$allow_customer_self_serve
|
||||
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
|
||||
: $elevated_permissions
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
|
||||
{
|
||||
if (self::hasPermission($permission)) {
|
||||
$elevated_permissions = [
|
||||
'modules_selfserve_lane_command_execute',
|
||||
$permission,
|
||||
];
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1281,21 +1399,70 @@ class moduleSelfServeRoute
|
||||
return;
|
||||
}
|
||||
|
||||
self::requirePermission($permission);
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
}
|
||||
|
||||
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $customer_number > 0
|
||||
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
|
||||
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall back to the persisted session lookup below.
|
||||
}
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
}
|
||||
|
||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||
{
|
||||
try {
|
||||
if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$department_id = (int)$lane->department_lane?->department?->value();
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
if ($department_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
try {
|
||||
$department = (new departments_o())->select($department_id);
|
||||
return $department->exists() && $department->getSelfServeEnabled();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function departmentIdForLane(selfserve_lane $lane): int
|
||||
{
|
||||
try {
|
||||
return (int)$lane->department_lane?->department?->value();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
|
||||
@@ -4,28 +4,20 @@ namespace routes;
|
||||
|
||||
use traits\route_t;
|
||||
|
||||
require_once dirname(__DIR__) . '/classes/cors_policy.php';
|
||||
|
||||
class optionsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// When the OPTIONS method is requested, accept all using regex
|
||||
$this->options('/.*', function () {
|
||||
global $CORS;
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *');
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
} else {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'CORS origin not allowed']);
|
||||
}
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use classes\cors_policy;
|
||||
|
||||
app_require('classes/cors_policy.php');
|
||||
|
||||
it('normalizes URL-like CORS entries to origins', function (): void {
|
||||
expect(cors_policy::normalizeOrigin('https://api-v2.truckwash.io/master/api'))
|
||||
->toBe('https://api-v2.truckwash.io');
|
||||
expect(cors_policy::normalizeOrigin('https://API-V2.TRUCKWASH.IO/canary/api/'))
|
||||
->toBe('https://api-v2.truckwash.io');
|
||||
expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping'))
|
||||
->toBe('https://api.truckwash.io:4433');
|
||||
});
|
||||
|
||||
it('merges required release and existing frontend origins into configured CORS', function (): void {
|
||||
$origins = cors_policy::allowedOrigins('https://example.test/app,https://api-v2.truckwash.io/master/api');
|
||||
|
||||
expect($origins)->toContain('https://api-v2.truckwash.io');
|
||||
expect($origins)->toContain('http://localhost:5173');
|
||||
expect($origins)->toContain('https://truckwash.io');
|
||||
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
|
||||
});
|
||||
|
||||
it('builds credential-safe normal CORS response headers for allowed origins', function (): void {
|
||||
$headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io');
|
||||
|
||||
expect($headers['Access-Control-Allow-Origin'])->toBe('http://localhost:5173');
|
||||
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
||||
expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH');
|
||||
expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace');
|
||||
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
|
||||
expect($headers['Access-Control-Max-Age'])->toBe('86400');
|
||||
expect($headers['Vary'])->toBe('Origin');
|
||||
});
|
||||
|
||||
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
|
||||
$preflight = cors_policy::preflightResponse(
|
||||
'https://api-v2.truckwash.io/master/api',
|
||||
'https://truckwash.io'
|
||||
);
|
||||
|
||||
expect($preflight['allowed'])->toBeTrue();
|
||||
expect($preflight['status'])->toBe(200);
|
||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
||||
expect($preflight['headers']['Content-Type'])->toBe('application/json');
|
||||
expect($preflight['body'])->toBe('');
|
||||
});
|
||||
|
||||
it('rejects unknown CORS origins', function (): void {
|
||||
$headers = cors_policy::responseHeaders('https://evil.example.test', 'https://truckwash.io');
|
||||
$preflight = cors_policy::preflightResponse('https://evil.example.test', 'https://truckwash.io');
|
||||
|
||||
expect($headers)->toBe([]);
|
||||
expect($preflight['allowed'])->toBeFalse();
|
||||
expect($preflight['status'])->toBe(403);
|
||||
expect($preflight['body'])->toContain('CORS origin not allowed');
|
||||
});
|
||||
|
||||
it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void {
|
||||
$headers = cors_policy::responseHeaders('https://partner.example.test', '*');
|
||||
|
||||
expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test');
|
||||
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
||||
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
||||
});
|
||||
@@ -10,9 +10,8 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
|
||||
];
|
||||
|
||||
$files = [
|
||||
app_path('index.php'),
|
||||
app_path('classes/cors_policy.php'),
|
||||
app_path('modules/washcertificates/index.php'),
|
||||
app_path('routes/optionsRoute.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
@@ -23,4 +22,7 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
|
||||
expect($content)->toContain($header);
|
||||
}
|
||||
}
|
||||
|
||||
expect((string)file_get_contents(app_path('index.php')))->toContain('cors_policy::preflightResponse');
|
||||
expect((string)file_get_contents(app_path('routes/optionsRoute.php')))->toContain('cors_policy::preflightResponse');
|
||||
});
|
||||
|
||||
@@ -273,6 +273,7 @@ it('builds explicit Coolify application route labels for release API targets', f
|
||||
->and($payload['force_domain_override'])->toBeTrue()
|
||||
->and($labels)->toContain('custom.keep=true')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
||||
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
||||
@@ -395,6 +396,66 @@ it('offers application target preparation for missing Coolify service creation f
|
||||
]))->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 release branch services out of the production Coolify environment except beta', function (): void {
|
||||
$manager = new release_manager();
|
||||
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
|
||||
@@ -491,6 +552,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
$response = file_get_contents(app_path('classes/response.php'));
|
||||
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
|
||||
$index = file_get_contents(app_path('index.php'));
|
||||
$corsPolicy = file_get_contents(app_path('classes/cors_policy.php'));
|
||||
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
|
||||
@@ -656,6 +718,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($manager)->toContain('coolify_enable_ssl');
|
||||
expect($manager)->toContain('createService');
|
||||
expect($manager)->toContain('updateService');
|
||||
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
|
||||
expect($manager)->toContain('channel_presets');
|
||||
expect($manager)->toContain('target_presets');
|
||||
|
||||
@@ -664,7 +727,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($status)->toContain("'key' => 'releasemanager'");
|
||||
expect($status)->toContain('probeReleaseManagerModule');
|
||||
expect($index)->toContain('release_manager::initializeRequestContext');
|
||||
expect($index)->toContain('X-Release-Trace');
|
||||
expect($corsPolicy)->toContain('X-Release-Trace');
|
||||
expect($manager)->toContain('X-Release-Channel');
|
||||
expect($manager)->toContain('normalizeReleaseApiIngressPath');
|
||||
expect($manager)->toContain('routeSlugForChannel');
|
||||
@@ -932,6 +995,7 @@ it('requires non-default release channel runtime URLs and preserves load balance
|
||||
'letsencrypt'
|
||||
);
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
|
||||
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
public bool $activeWashResult = false;
|
||||
/** @var array<string,bool> */
|
||||
public array $permissions = [
|
||||
'list_own_department_selfserve_vehicle_conditions' => true,
|
||||
];
|
||||
/** @var array<int,array{department_id:int,customer_number:int}> */
|
||||
public array $activeWashChecks = [];
|
||||
|
||||
public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool
|
||||
{
|
||||
$key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission;
|
||||
return $this->permissions[$key] ?? false;
|
||||
}
|
||||
|
||||
public function canUsePropertyGate(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
{
|
||||
$this->activeWashChecks[] = [
|
||||
@@ -81,6 +96,16 @@ it('does not bypass property gate permissions without a positive customer number
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('does not bypass property gate permissions without customer self-serve permission', function (): void {
|
||||
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
|
||||
$route->activeWashResult = true;
|
||||
$route->permissions = [];
|
||||
$lane = selfserve_property_gate_permission_lane_for_department(6);
|
||||
|
||||
expect($route->canUsePropertyGate($lane, 12345679))->toBeFalse();
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('does not bypass property gate permissions when the customer has no active wash in the target department', function (): void {
|
||||
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
|
||||
$route->activeWashResult = false;
|
||||
|
||||
@@ -28,7 +28,7 @@ http:
|
||||
- main: api.truckwash.dk
|
||||
|
||||
api-preflight-io:
|
||||
rule: (Host(`api.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
|
||||
rule: (Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
|
||||
entryPoints: [websecure, websecure-staging]
|
||||
middlewares: [secure-headers]
|
||||
service: noop@internal
|
||||
@@ -37,6 +37,7 @@ http:
|
||||
certResolver: le_io
|
||||
domains:
|
||||
- main: api.truckwash.io
|
||||
- main: api-v2.truckwash.io
|
||||
|
||||
cloud-preflight:
|
||||
rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`)
|
||||
@@ -82,6 +83,7 @@ http:
|
||||
- "https://www.truckwash.io"
|
||||
- "https://api.truckwash.io"
|
||||
- "https://api.truckwash.io:4433"
|
||||
- "https://api-v2.truckwash.io"
|
||||
- "https://web.truckwash.dk"
|
||||
- "https://api.truckwash.dk"
|
||||
- "https://truckwash.dk"
|
||||
@@ -107,6 +109,8 @@ http:
|
||||
- X-Release-Trace
|
||||
- X-Release-Channel
|
||||
- X-Frontend-Version
|
||||
- Cache-Control
|
||||
- Pragma
|
||||
api-ratelimit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
|
||||
Reference in New Issue
Block a user