Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430c90cbca | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
38814545c4 | ||
|
|
94c3654240 | ||
|
|
9fa249cc11 | ||
|
|
215c8d0fbb | ||
|
|
3f41eebdf6 | ||
|
|
64e0b2444b | ||
|
|
18fede78f8 | ||
|
|
243d68ab59 | ||
|
|
62c1393f62 | ||
|
|
f0a8299133 | ||
|
|
e36f6da926 | ||
|
|
4a8c2a9fd9 | ||
|
|
248b2e4eca | ||
|
|
1d1ebd2176 | ||
|
|
4252f9a42b | ||
|
|
4fdeedab45 | ||
|
|
866a5be126 | ||
|
|
11d39af934 | ||
|
|
f706531534 | ||
|
|
a839eac4c1 | ||
|
|
581d28e9ce | ||
|
|
2ba39b8174 | ||
|
|
6af55a44c9 | ||
|
|
24badc39d7 | ||
|
|
f5e0baaab6 | ||
|
|
178c84ba60 | ||
|
|
713d40a876 | ||
|
|
9db1964038 | ||
|
|
1ca42055b0 | ||
|
|
c04bda7368 | ||
|
|
1f47843699 | ||
|
|
dca738db82 | ||
|
|
57f364ad0f | ||
|
|
a826153bb5 | ||
|
|
72bd22a707 | ||
|
|
cc73d80dbc | ||
|
|
f0a5b15442 | ||
|
|
eefe5630f4 | ||
|
|
b0ea771e6a | ||
|
|
eb21405a3d | ||
|
|
8ea10ef808 | ||
|
|
ac7da807bd | ||
|
|
3eafc597c6 | ||
|
|
f7a4126718 | ||
|
|
c6cf953ede | ||
|
|
d902202fe9 | ||
|
|
acc80920f9 | ||
|
|
53246af629 | ||
|
|
bd87e94472 | ||
|
|
7ccbb68ffa | ||
|
|
4a7fc7c534 | ||
|
|
f4343ae114 | ||
|
|
0da02dfeb5 | ||
|
|
5f13242cfa | ||
|
|
b492292642 | ||
|
|
ce8ba88b16 | ||
|
|
02b6df5e3b | ||
|
|
6cc4f2759d | ||
|
|
3ea92be722 | ||
|
|
42f8ae0c47 | ||
|
|
148b575767 | ||
|
|
605efacece |
@@ -11,6 +11,8 @@ services:
|
|||||||
|
|
||||||
edge-broker:
|
edge-broker:
|
||||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||||
@@ -80,3 +82,9 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ci_php_app:
|
ci_php_app:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
qodana:
|
qodana:
|
||||||
# CI runs on the repository's self-hosted runner pool.
|
# CI runs on the repository's self-hosted runner pool.
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
|
|||||||
+97
-40
@@ -7,7 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
php:
|
php:
|
||||||
name: PHP ${{ matrix.suite }} (required)
|
name: PHP ${{ matrix.suite }} (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -19,6 +19,20 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Docker access
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if docker ps >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo -n chmod 666 /var/run/docker.sock
|
||||||
|
else
|
||||||
|
chmod 666 /var/run/docker.sock
|
||||||
|
fi
|
||||||
|
docker ps >/dev/null
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: ${{ matrix.suite == 'unit' }}
|
if: ${{ matrix.suite == 'unit' }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -44,7 +58,7 @@ jobs:
|
|||||||
|
|
||||||
edge-agent:
|
edge-agent:
|
||||||
name: Edge Agent (required)
|
name: Edge Agent (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -54,8 +68,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: services/edge-agent/package-lock.json
|
|
||||||
|
|
||||||
- name: Install native build tools
|
- name: Install native build tools
|
||||||
run: |
|
run: |
|
||||||
@@ -91,12 +103,26 @@ jobs:
|
|||||||
|
|
||||||
edge-broker:
|
edge-broker:
|
||||||
name: Edge Broker (required)
|
name: Edge Broker (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Docker access
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if docker ps >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo -n chmod 666 /var/run/docker.sock
|
||||||
|
else
|
||||||
|
chmod 666 /var/run/docker.sock
|
||||||
|
fi
|
||||||
|
docker ps >/dev/null
|
||||||
|
|
||||||
- name: Materialize CI compose env files
|
- name: Materialize CI compose env files
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -112,8 +138,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: services/edge-broker/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: services/edge-broker
|
working-directory: services/edge-broker
|
||||||
@@ -125,26 +149,83 @@ jobs:
|
|||||||
|
|
||||||
edge-gateway-backend:
|
edge-gateway-backend:
|
||||||
name: Edge Gateway Backend (required)
|
name: Edge Gateway Backend (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
COMPOSE_PROFILES: dev
|
||||||
TRAEFIK_WEB_PORT: "18080"
|
TRAEFIK_WEB_PORT: "18080"
|
||||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||||
TRAEFIK_METRICS_PORT: "19100"
|
TRAEFIK_METRICS_PORT: "19100"
|
||||||
|
EDGE_BROKER_CI_PORT: "14300"
|
||||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure Docker access
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if docker ps >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo -n chmod 666 /var/run/docker.sock
|
||||||
|
else
|
||||||
|
chmod 666 /var/run/docker.sock
|
||||||
|
fi
|
||||||
|
docker ps >/dev/null
|
||||||
|
|
||||||
|
- name: Allocate CI ports
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
find_free_port() {
|
||||||
|
start="$1"
|
||||||
|
end="$2"
|
||||||
|
port="$start"
|
||||||
|
while [ "$port" -le "$end" ]; do
|
||||||
|
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
|
||||||
|
echo "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
port=$((port + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "No free port in range ${start}-${end}." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
base=$((20000 + (GITHUB_RUN_ID % 20000)))
|
||||||
|
web_port="$(find_free_port "$base" "$((base + 2000))")"
|
||||||
|
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
|
||||||
|
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
|
||||||
|
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
|
||||||
|
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
|
||||||
|
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||||
|
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||||
|
subnet_third=$((checksum % 256))
|
||||||
|
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "TRAEFIK_WEB_PORT=${web_port}"
|
||||||
|
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
|
||||||
|
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
|
||||||
|
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
|
||||||
|
echo "EDGE_BROKER_CI_PORT=${broker_port}"
|
||||||
|
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
|
||||||
|
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
|
||||||
|
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Materialize CI compose env files
|
- name: Materialize CI compose env files
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cp .github/ci.env .env
|
cp .github/ci.env .env
|
||||||
cp .github/ci.env.staging .env.staging
|
cp .github/ci.env.staging .env.staging
|
||||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -152,7 +233,7 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Boot local stack
|
- name: Boot local stack
|
||||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||||
|
|
||||||
- name: Sync PHP app checkout
|
- name: Sync PHP app checkout
|
||||||
run: >
|
run: >
|
||||||
@@ -161,7 +242,7 @@ jobs:
|
|||||||
--exclude='./.phpunit.cache'
|
--exclude='./.phpunit.cache'
|
||||||
--exclude='./build/logs'
|
--exclude='./build/logs'
|
||||||
-C services/nginx/app -cf - .
|
-C services/nginx/app -cf - .
|
||||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||||
|
|
||||||
- name: Resolve dependencies
|
- name: Resolve dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -246,34 +327,10 @@ jobs:
|
|||||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||||
|
|
||||||
- name: Run edge gateway E2E smoke
|
- name: Run edge gateway E2E smoke
|
||||||
run: |
|
env:
|
||||||
set -euo pipefail
|
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
run: node scripts/edge-gateway-e2e.mjs
|
||||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
|
||||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
|
||||||
docker create \
|
|
||||||
--name "$runner" \
|
|
||||||
--network "${compose_project}_default" \
|
|
||||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
|
||||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
|
||||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
|
||||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
|
||||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
|
||||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
|
||||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
|
||||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
|
||||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
|
||||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-w /workspace \
|
|
||||||
node:22-alpine \
|
|
||||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
|
||||||
docker cp . "$runner:/workspace"
|
|
||||||
docker start "$runner" >/dev/null
|
|
||||||
docker logs -f "$runner"
|
|
||||||
exit_code="$(docker wait "$runner")"
|
|
||||||
exit "$exit_code"
|
|
||||||
|
|
||||||
- name: Tear down local stack
|
- name: Tear down local stack
|
||||||
if: always()
|
if: always()
|
||||||
@@ -281,7 +338,7 @@ jobs:
|
|||||||
|
|
||||||
release-manager-gate:
|
release-manager-gate:
|
||||||
name: Release Manager gate
|
name: Release Manager gate
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -13,4 +13,7 @@
|
|||||||
.env.old
|
.env.old
|
||||||
/.tmp/
|
/.tmp/
|
||||||
/.env.staging
|
/.env.staging
|
||||||
/services/nginx/app/storage/replication-bootstrap.json
|
/services/nginx/app/storage/replication-bootstrap.json
|
||||||
|
/.env_old_2
|
||||||
|
/.openclaw/
|
||||||
|
/services/nginx/app/build/phpstan/
|
||||||
|
|||||||
+132
-3
@@ -7462,7 +7462,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [reg, type, wash_subscription]
|
required: [reg, type]
|
||||||
properties:
|
properties:
|
||||||
reg:
|
reg:
|
||||||
type: string
|
type: string
|
||||||
@@ -7474,6 +7474,8 @@ paths:
|
|||||||
description: Product ID representing the vehicle wash type
|
description: Product ID representing the vehicle wash type
|
||||||
wash_subscription:
|
wash_subscription:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: Defaults to false when omitted.
|
||||||
reference:
|
reference:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
@@ -11665,6 +11667,70 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
||||||
|
|
||||||
|
/slack/config/test:
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Test Slack customer registration webhook
|
||||||
|
operationId: testSlackCustomerRegistrationWebhook
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack customer registration webhook test completed successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||||
|
'400':
|
||||||
|
description: Slack customer registration webhook URL is not configured
|
||||||
|
'502':
|
||||||
|
description: Slack customer registration webhook test failed
|
||||||
|
|
||||||
|
/slack/config/internal-department-goal-progress:
|
||||||
|
get:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Get Slack internal department goal progress config
|
||||||
|
operationId: getSlackInternalDepartmentGoalProgressConfig
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress configuration retrieved successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Update Slack internal department goal progress config
|
||||||
|
operationId: updateSlackInternalDepartmentGoalProgressConfig
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigUpdate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress configuration updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||||
|
|
||||||
|
/slack/config/internal-department-goal-progress/test:
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Test Slack internal department goal progress webhook
|
||||||
|
operationId: testSlackInternalDepartmentGoalProgressWebhook
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress webhook test completed successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||||
|
'400':
|
||||||
|
description: Slack internal department goal progress webhook URL is not configured
|
||||||
|
'502':
|
||||||
|
description: Slack internal department goal progress webhook test failed
|
||||||
|
|
||||||
/backups/config:
|
/backups/config:
|
||||||
get:
|
get:
|
||||||
tags: [Config]
|
tags: [Config]
|
||||||
@@ -15183,12 +15249,59 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
module: { type: string, enum: [Slack] }
|
module: { type: string, enum: [Slack] }
|
||||||
variable: { type: string, enum: [customer_registration_webhook_url] }
|
variable: { type: string, enum: [customer_registration_webhook_url, internal_department_goal_progress_webhook_url, internal_department_ids] }
|
||||||
type: { type: string, enum: [string] }
|
type: { type: string, enum: [string] }
|
||||||
value:
|
value:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
example: https://hooks.slack.com/services/...
|
||||||
|
- type: string
|
||||||
|
example: '[1,2,3]'
|
||||||
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
|
SlackConfigTestResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
sent: { type: boolean }
|
||||||
|
message: { type: string }
|
||||||
|
required: [configured, sent, message]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressDepartment:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
name: { type: string }
|
||||||
|
order_priority: { type: integer }
|
||||||
|
required: [id, name, order_priority]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfig:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
internal_department_goal_progress_webhook_url:
|
||||||
type: string
|
type: string
|
||||||
example: https://hooks.slack.com/services/...
|
example: https://hooks.slack.com/services/...
|
||||||
required: [module, variable, type, value]
|
internal_department_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: integer }
|
||||||
|
example: [1, 2, 3]
|
||||||
|
departments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressDepartment'
|
||||||
|
required: [internal_department_goal_progress_webhook_url, internal_department_ids, departments]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfigUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
internal_department_goal_progress_webhook_url:
|
||||||
|
type: string
|
||||||
|
example: https://hooks.slack.com/services/...
|
||||||
|
internal_department_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: integer }
|
||||||
|
example: [1, 2, 3]
|
||||||
|
required: [internal_department_goal_progress_webhook_url, internal_department_ids]
|
||||||
|
|
||||||
BackupsConfigEntry:
|
BackupsConfigEntry:
|
||||||
type: object
|
type: object
|
||||||
@@ -15466,6 +15579,22 @@ components:
|
|||||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
||||||
required: [data]
|
required: [data]
|
||||||
|
|
||||||
|
SlackConfigTestResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||||
|
required: [data]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfigResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfig' }
|
||||||
|
required: [data]
|
||||||
|
|
||||||
BackupsConfigListResponse:
|
BackupsConfigListResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <service> [service ...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
|
||||||
|
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
|
||||||
|
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
|
||||||
|
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||||
|
|
||||||
|
compose_up() {
|
||||||
|
attempt=1
|
||||||
|
while :; do
|
||||||
|
docker network prune -f >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if docker compose $compose_files up -d "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
status="$?"
|
||||||
|
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||||
|
return "$status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep_seconds=$((attempt * 5))
|
||||||
|
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
|
||||||
|
sleep "$sleep_seconds"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v flock >/dev/null 2>&1; then
|
||||||
|
(
|
||||||
|
flock 9
|
||||||
|
compose_up "$@"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
else
|
||||||
|
echo "flock is not available; running Docker compose startup without a host lock." >&2
|
||||||
|
compose_up "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const DEFAULT_AGENT_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||||
|
);
|
||||||
|
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
function parseArgs(argv = process.argv.slice(2)) {
|
||||||
|
const options = {
|
||||||
|
agentPath: DEFAULT_AGENT_PATH,
|
||||||
|
phpImage: DEFAULT_PHP_IMAGE,
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
keepTemp: false,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const next = argv[index + 1];
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "--agent-path":
|
||||||
|
options.agentPath = path.resolve(String(next || "").trim());
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--php-image":
|
||||||
|
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--timeout-ms":
|
||||||
|
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--keep-temp":
|
||||||
|
options.keepTemp = true;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
options.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
process.stdout.write(`Usage:
|
||||||
|
node scripts/edge-agent-command-drain-proof.mjs [options]
|
||||||
|
|
||||||
|
Verifies that a broker-connected PHP compose edge agent still drains API-queued
|
||||||
|
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--agent-path <path> PHP agent artifact to execute.
|
||||||
|
Default: ${DEFAULT_AGENT_PATH}
|
||||||
|
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||||
|
Default: ${DEFAULT_PHP_IMAGE}
|
||||||
|
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||||
|
--keep-temp Keep the temporary config/runtime directory.
|
||||||
|
--help Show this help text.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(request) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let raw = "";
|
||||||
|
request.setEncoding("utf8");
|
||||||
|
request.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
request.on("end", () => {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
resolve({ __invalid: raw });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, status, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
response.writeHead(status, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"content-length": Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketAcceptKey(key) {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha1")
|
||||||
|
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||||
|
.digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrokerServer(state) {
|
||||||
|
const sockets = new Set();
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on("close", () => sockets.delete(socket));
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("binary");
|
||||||
|
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||||
|
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||||
|
const requestLine = requestText.split("\r\n")[0] || "";
|
||||||
|
if (!requestLine.includes("/ws/agent?")) {
|
||||||
|
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.write([
|
||||||
|
"HTTP/1.1 101 Switching Protocols",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n"));
|
||||||
|
state.brokerHandshakeSeen = true;
|
||||||
|
buffer = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { server, sockets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkerServer(state) {
|
||||||
|
return http.createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
const body = await readJson(request);
|
||||||
|
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/health") {
|
||||||
|
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/relay/switch") {
|
||||||
|
state.relaySwitchSeen = true;
|
||||||
|
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
|
||||||
|
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
sendJson(response, 200, {
|
||||||
|
online: true,
|
||||||
|
on: true,
|
||||||
|
output: true,
|
||||||
|
raw: { source: "fake-worker" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApiServer(state, brokerPort, workerPort) {
|
||||||
|
return http.createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
const body = await readJson(request);
|
||||||
|
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||||
|
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||||
|
sendJson(response, 200, { data: { monitors: [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||||
|
state.commandPollSeen = true;
|
||||||
|
if (body.wait_seconds !== 0) {
|
||||||
|
state.failure = new Error(
|
||||||
|
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.commandDelivered) {
|
||||||
|
state.commandDelivered = true;
|
||||||
|
sendJson(response, 200, {
|
||||||
|
data: {
|
||||||
|
id: 77,
|
||||||
|
command_type: "SET_RELAY_STATE",
|
||||||
|
payload: {
|
||||||
|
localIp: "10.123.0.31",
|
||||||
|
channel: 0,
|
||||||
|
on: true,
|
||||||
|
relayId: "relay-proof",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 200, { data: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||||
|
state.resultSeen = true;
|
||||||
|
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
|
||||||
|
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { data: { acknowledged: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||||
|
const containerProofDir = "/proof";
|
||||||
|
const runtimeDir = `${containerProofDir}/runtime`;
|
||||||
|
const config = {
|
||||||
|
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||||
|
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||||
|
gatewayId: 42,
|
||||||
|
agentToken: "agent-token",
|
||||||
|
installDir: containerProofDir,
|
||||||
|
runtimeDir,
|
||||||
|
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||||
|
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||||
|
heartbeatIntervalSeconds: 60,
|
||||||
|
operationPollTimeoutSeconds: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const configPath = path.join(tempDir, "config.json");
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||||
|
return spawn("docker", [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"-v",
|
||||||
|
`${agentPath}:/agent.php:ro`,
|
||||||
|
"-v",
|
||||||
|
`${tempDir}:/proof`,
|
||||||
|
phpImage,
|
||||||
|
"php",
|
||||||
|
"/agent.php",
|
||||||
|
"--config",
|
||||||
|
containerConfigPath,
|
||||||
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopChild(child) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
const hardKill = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve) => child.once("exit", resolve)),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||||
|
]);
|
||||||
|
clearTimeout(hardKill);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceFromState(state, childExited) {
|
||||||
|
return {
|
||||||
|
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||||
|
commandPollSeen: state.commandPollSeen,
|
||||||
|
relaySwitchSeen: state.relaySwitchSeen,
|
||||||
|
resultSeen: state.resultSeen,
|
||||||
|
agentStayedRunningUntilProofComplete: !childExited,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProof(options) {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(options.agentPath)) {
|
||||||
|
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
|
||||||
|
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
brokerHandshakeSeen: false,
|
||||||
|
commandPollSeen: false,
|
||||||
|
relaySwitchSeen: false,
|
||||||
|
resultSeen: false,
|
||||||
|
commandDelivered: false,
|
||||||
|
failure: null,
|
||||||
|
requests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const broker = createBrokerServer(state);
|
||||||
|
const workerServer = createWorkerServer(state);
|
||||||
|
let apiServer = null;
|
||||||
|
let child = null;
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let childExited = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brokerPort = await listen(broker.server);
|
||||||
|
const workerPort = await listen(workerServer);
|
||||||
|
apiServer = createApiServer(state, brokerPort, workerPort);
|
||||||
|
const apiPort = await listen(apiServer);
|
||||||
|
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||||
|
|
||||||
|
child = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.once("exit", () => {
|
||||||
|
childExited = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
while (Date.now() < deadline && !state.failure && !childExited) {
|
||||||
|
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = evidenceFromState(state, childExited);
|
||||||
|
if (
|
||||||
|
state.failure ||
|
||||||
|
!state.brokerHandshakeSeen ||
|
||||||
|
!state.commandPollSeen ||
|
||||||
|
!state.relaySwitchSeen ||
|
||||||
|
!state.resultSeen
|
||||||
|
) {
|
||||||
|
const error = state.failure || new Error("missing proof evidence");
|
||||||
|
error.evidence = evidence;
|
||||||
|
error.requests = state.requests;
|
||||||
|
error.stdout = stdout.slice(-3000);
|
||||||
|
error.stderr = stderr.slice(-3000);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
evidence,
|
||||||
|
agentPath: options.agentPath,
|
||||||
|
phpImage: options.phpImage,
|
||||||
|
tempDir,
|
||||||
|
requestCount: state.requests.length,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (child) {
|
||||||
|
await stopChild(child);
|
||||||
|
}
|
||||||
|
for (const socket of broker.sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
closeServer(broker.server),
|
||||||
|
closeServer(workerServer),
|
||||||
|
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
if (!options.keepTemp) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
if (options.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runProof(options);
|
||||||
|
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
|
||||||
|
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||||
|
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||||
|
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||||
|
if (options.keepTemp) {
|
||||||
|
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`FAIL ${error.message}\n`);
|
||||||
|
if (error.evidence) {
|
||||||
|
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||||
|
}
|
||||||
|
if (error.requests) {
|
||||||
|
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
if (error.stdout) {
|
||||||
|
process.stderr.write(`stdout: ${error.stdout}\n`);
|
||||||
|
}
|
||||||
|
if (error.stderr) {
|
||||||
|
process.stderr.write(`stderr: ${error.stderr}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const DEFAULT_AGENT_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||||
|
);
|
||||||
|
const DEFAULT_WORKER_PATH = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
|
||||||
|
);
|
||||||
|
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15000;
|
||||||
|
const AGENT_TOKEN = "agent-token";
|
||||||
|
|
||||||
|
function parseArgs(argv = process.argv.slice(2)) {
|
||||||
|
const options = {
|
||||||
|
agentPath: DEFAULT_AGENT_PATH,
|
||||||
|
workerPath: DEFAULT_WORKER_PATH,
|
||||||
|
phpImage: DEFAULT_PHP_IMAGE,
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
keepTemp: false,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const next = argv[index + 1];
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "--agent-path":
|
||||||
|
options.agentPath = path.resolve(String(next || "").trim());
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--worker-path":
|
||||||
|
options.workerPath = path.resolve(String(next || "").trim());
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--php-image":
|
||||||
|
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--timeout-ms":
|
||||||
|
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
case "--keep-temp":
|
||||||
|
options.keepTemp = true;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
options.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
process.stdout.write(`Usage:
|
||||||
|
node scripts/edge-agent-to-shelly-proof.mjs [options]
|
||||||
|
|
||||||
|
Runs the PHP edge agent and real LAN worker against fake broker, API, and
|
||||||
|
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
|
||||||
|
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
|
||||||
|
call, reads Switch.GetStatus, and posts the command result.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--agent-path <path> PHP agent artifact to execute.
|
||||||
|
Default: ${DEFAULT_AGENT_PATH}
|
||||||
|
--worker-path <path> PHP LAN worker artifact to execute.
|
||||||
|
Default: ${DEFAULT_WORKER_PATH}
|
||||||
|
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||||
|
Default: ${DEFAULT_PHP_IMAGE}
|
||||||
|
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||||
|
--keep-temp Keep the temporary config/runtime directory.
|
||||||
|
--help Show this help text.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(request) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let raw = "";
|
||||||
|
request.setEncoding("utf8");
|
||||||
|
request.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
request.on("end", () => {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
resolve({ __invalid: raw });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, status, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
response.writeHead(status, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"content-length": Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = body === null ? null : JSON.stringify(body);
|
||||||
|
const request = http.request({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
path: requestPath,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
...(payload === null ? {} : {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(payload),
|
||||||
|
}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
timeout: 1000,
|
||||||
|
}, (response) => {
|
||||||
|
let raw = "";
|
||||||
|
response.setEncoding("utf8");
|
||||||
|
response.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
response.on("end", () => {
|
||||||
|
let decoded = {};
|
||||||
|
try {
|
||||||
|
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
decoded = { __invalid: raw };
|
||||||
|
}
|
||||||
|
resolve({ status: response.statusCode || 0, body: decoded });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.on("error", reject);
|
||||||
|
request.on("timeout", () => {
|
||||||
|
request.destroy(new Error("request timed out"));
|
||||||
|
});
|
||||||
|
if (payload !== null) {
|
||||||
|
request.write(payload);
|
||||||
|
}
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reservePort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
const port = await new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||||
|
});
|
||||||
|
await closeServer(server);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketAcceptKey(key) {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha1")
|
||||||
|
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||||
|
.digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrokerServer(state) {
|
||||||
|
const sockets = new Set();
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on("close", () => sockets.delete(socket));
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("binary");
|
||||||
|
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||||
|
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||||
|
const requestLine = requestText.split("\r\n")[0] || "";
|
||||||
|
if (!requestLine.includes("/ws/agent?")) {
|
||||||
|
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.write([
|
||||||
|
"HTTP/1.1 101 Switching Protocols",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n"));
|
||||||
|
state.brokerHandshakeSeen = true;
|
||||||
|
buffer = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { server, sockets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShellyServer(state) {
|
||||||
|
return http.createServer((request, response) => {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
state.requests.push({
|
||||||
|
service: "shelly",
|
||||||
|
method: request.method,
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams.entries()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
|
||||||
|
state.shellySwitchSetSeen = true;
|
||||||
|
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
|
||||||
|
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { was_on: false, output: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
|
||||||
|
state.shellyStatusSeen = true;
|
||||||
|
if (url.searchParams.get("id") !== "0") {
|
||||||
|
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/relay/")) {
|
||||||
|
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApiServer(state, brokerPort, shellyAddress) {
|
||||||
|
return http.createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
const body = await readJson(request);
|
||||||
|
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||||
|
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||||
|
sendJson(response, 200, { data: { monitors: [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||||
|
state.commandPollSeen = true;
|
||||||
|
if (body.wait_seconds !== 0) {
|
||||||
|
state.failure = new Error(
|
||||||
|
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.commandDelivered) {
|
||||||
|
state.commandDelivered = true;
|
||||||
|
sendJson(response, 200, {
|
||||||
|
data: {
|
||||||
|
id: 77,
|
||||||
|
command_type: "SET_RELAY_STATE",
|
||||||
|
payload: {
|
||||||
|
localIp: shellyAddress,
|
||||||
|
local_ip: shellyAddress,
|
||||||
|
channel: 0,
|
||||||
|
on: true,
|
||||||
|
relayId: "relay-proof",
|
||||||
|
relay_id: "relay-proof",
|
||||||
|
deviceGeneration: 2,
|
||||||
|
device_generation: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 200, { data: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||||
|
state.resultSeen = true;
|
||||||
|
if (
|
||||||
|
body.ok !== true ||
|
||||||
|
body.result?.on !== true ||
|
||||||
|
body.result?.output !== true ||
|
||||||
|
body.result?.raw?.source !== "fake-shelly-rpc"
|
||||||
|
) {
|
||||||
|
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { data: { acknowledged: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { message: "not found", path: url.pathname });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||||
|
const containerProofDir = "/proof";
|
||||||
|
const runtimeDir = `${containerProofDir}/runtime`;
|
||||||
|
const config = {
|
||||||
|
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||||
|
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||||
|
gatewayId: 42,
|
||||||
|
agentToken: AGENT_TOKEN,
|
||||||
|
installDir: containerProofDir,
|
||||||
|
runtimeDir,
|
||||||
|
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||||
|
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||||
|
heartbeatIntervalSeconds: 60,
|
||||||
|
operationPollTimeoutSeconds: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const configPath = path.join(tempDir, "config.json");
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
return { containerConfigPath: `${containerProofDir}/config.json` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnWorker({ workerPath, phpImage, workerPort }) {
|
||||||
|
return spawn("docker", [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"-e",
|
||||||
|
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
|
||||||
|
"-v",
|
||||||
|
`${workerPath}:/lan-worker.php:ro`,
|
||||||
|
phpImage,
|
||||||
|
"php",
|
||||||
|
"-S",
|
||||||
|
`127.0.0.1:${workerPort}`,
|
||||||
|
"/lan-worker.php",
|
||||||
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||||
|
return spawn("docker", [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"-v",
|
||||||
|
`${agentPath}:/agent.php:ro`,
|
||||||
|
"-v",
|
||||||
|
`${tempDir}:/proof`,
|
||||||
|
phpImage,
|
||||||
|
"php",
|
||||||
|
"/agent.php",
|
||||||
|
"--config",
|
||||||
|
containerConfigPath,
|
||||||
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWorker(workerPort, child, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await requestJson({ port: workerPort, path: "/health" });
|
||||||
|
if (response.status === 200 && response.body?.service === "lan-worker") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw lastError || new Error("LAN worker did not become healthy");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopChild(child) {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
const hardKill = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve) => child.once("exit", resolve)),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||||
|
]);
|
||||||
|
clearTimeout(hardKill);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceFromState(state, agentExited, workerExited) {
|
||||||
|
return {
|
||||||
|
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||||
|
commandPollSeen: state.commandPollSeen,
|
||||||
|
shellySwitchSetSeen: state.shellySwitchSetSeen,
|
||||||
|
shellyStatusSeen: state.shellyStatusSeen,
|
||||||
|
resultSeen: state.resultSeen,
|
||||||
|
agentStayedRunningUntilProofComplete: !agentExited,
|
||||||
|
workerStayedRunningUntilProofComplete: !workerExited,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProof(options) {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(options.agentPath)) {
|
||||||
|
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(options.workerPath)) {
|
||||||
|
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
|
||||||
|
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
brokerHandshakeSeen: false,
|
||||||
|
commandPollSeen: false,
|
||||||
|
shellySwitchSetSeen: false,
|
||||||
|
shellyStatusSeen: false,
|
||||||
|
resultSeen: false,
|
||||||
|
commandDelivered: false,
|
||||||
|
failure: null,
|
||||||
|
requests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const broker = createBrokerServer(state);
|
||||||
|
const shellyServer = createShellyServer(state);
|
||||||
|
let apiServer = null;
|
||||||
|
let agent = null;
|
||||||
|
let worker = null;
|
||||||
|
let agentStdout = "";
|
||||||
|
let agentStderr = "";
|
||||||
|
let workerStdout = "";
|
||||||
|
let workerStderr = "";
|
||||||
|
let agentExited = false;
|
||||||
|
let workerExited = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brokerPort = await listen(broker.server);
|
||||||
|
const shellyPort = await listen(shellyServer);
|
||||||
|
const workerPort = await reservePort();
|
||||||
|
const shellyAddress = `127.0.0.1:${shellyPort}`;
|
||||||
|
apiServer = createApiServer(state, brokerPort, shellyAddress);
|
||||||
|
const apiPort = await listen(apiServer);
|
||||||
|
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||||
|
|
||||||
|
worker = spawnWorker({ ...options, workerPort });
|
||||||
|
worker.stdout.on("data", (chunk) => {
|
||||||
|
workerStdout += chunk.toString();
|
||||||
|
});
|
||||||
|
worker.stderr.on("data", (chunk) => {
|
||||||
|
workerStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
worker.once("exit", () => {
|
||||||
|
workerExited = true;
|
||||||
|
});
|
||||||
|
await waitForWorker(workerPort, worker, 5000);
|
||||||
|
|
||||||
|
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||||
|
agent.stdout.on("data", (chunk) => {
|
||||||
|
agentStdout += chunk.toString();
|
||||||
|
});
|
||||||
|
agent.stderr.on("data", (chunk) => {
|
||||||
|
agentStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
agent.once("exit", () => {
|
||||||
|
agentExited = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
|
||||||
|
if (
|
||||||
|
state.brokerHandshakeSeen &&
|
||||||
|
state.commandPollSeen &&
|
||||||
|
state.shellySwitchSetSeen &&
|
||||||
|
state.shellyStatusSeen &&
|
||||||
|
state.resultSeen
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = evidenceFromState(state, agentExited, workerExited);
|
||||||
|
if (
|
||||||
|
state.failure ||
|
||||||
|
!state.brokerHandshakeSeen ||
|
||||||
|
!state.commandPollSeen ||
|
||||||
|
!state.shellySwitchSetSeen ||
|
||||||
|
!state.shellyStatusSeen ||
|
||||||
|
!state.resultSeen
|
||||||
|
) {
|
||||||
|
const error = state.failure || new Error("missing proof evidence");
|
||||||
|
error.evidence = evidence;
|
||||||
|
error.requests = state.requests;
|
||||||
|
error.agentStdout = agentStdout.slice(-3000);
|
||||||
|
error.agentStderr = agentStderr.slice(-3000);
|
||||||
|
error.workerStdout = workerStdout.slice(-3000);
|
||||||
|
error.workerStderr = workerStderr.slice(-3000);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
evidence,
|
||||||
|
agentPath: options.agentPath,
|
||||||
|
workerPath: options.workerPath,
|
||||||
|
phpImage: options.phpImage,
|
||||||
|
tempDir,
|
||||||
|
requestCount: state.requests.length,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (agent) {
|
||||||
|
await stopChild(agent);
|
||||||
|
}
|
||||||
|
if (worker) {
|
||||||
|
await stopChild(worker);
|
||||||
|
}
|
||||||
|
for (const socket of broker.sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
closeServer(broker.server),
|
||||||
|
closeServer(shellyServer),
|
||||||
|
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
if (!options.keepTemp) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
if (options.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runProof(options);
|
||||||
|
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
|
||||||
|
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||||
|
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||||
|
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
|
||||||
|
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||||
|
if (options.keepTemp) {
|
||||||
|
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`FAIL ${error.message}\n`);
|
||||||
|
if (error.evidence) {
|
||||||
|
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||||
|
}
|
||||||
|
if (error.requests) {
|
||||||
|
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
if (error.agentStdout) {
|
||||||
|
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
|
||||||
|
}
|
||||||
|
if (error.agentStderr) {
|
||||||
|
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
|
||||||
|
}
|
||||||
|
if (error.workerStdout) {
|
||||||
|
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
|
||||||
|
}
|
||||||
|
if (error.workerStderr) {
|
||||||
|
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
+112
-39
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
|
|||||||
return normalizeBaseUrl(url.toString());
|
return normalizeBaseUrl(url.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocalHost(hostname) {
|
||||||
|
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||||
|
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||||
const websocketUrl = new URL(String(rawUrl));
|
const websocketUrl = new URL(String(rawUrl));
|
||||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||||
|
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
|
||||||
|
|
||||||
|
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
|
||||||
|
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
websocketUrl.hostname = apiUrl.hostname;
|
||||||
|
websocketUrl.port = ciBrokerPort;
|
||||||
|
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||||
|
return websocketUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||||
websocketUrl.hostname = "edge-broker";
|
websocketUrl.hostname = "edge-broker";
|
||||||
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
|||||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
|
||||||
|
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||||
|
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
websocketUrl.hostname = apiUrl.hostname;
|
||||||
|
websocketUrl.port = apiUrl.port;
|
||||||
|
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocalHost(websocketUrl.hostname)) {
|
||||||
|
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
return websocketUrl.toString();
|
return websocketUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
|
||||||
|
const diagnostics = {};
|
||||||
|
|
||||||
|
if (gatewayId !== null && gatewayId > 0 && authToken) {
|
||||||
|
try {
|
||||||
|
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||||
|
token: authToken,
|
||||||
|
});
|
||||||
|
diagnostics.gateway = {
|
||||||
|
status: detail?.data?.status ?? null,
|
||||||
|
channelStatus: detail?.data?.channel_status ?? null,
|
||||||
|
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
|
||||||
|
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
|
||||||
|
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
|
||||||
|
allowFailure: true,
|
||||||
|
});
|
||||||
|
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const scriptPath = fileURLToPath(import.meta.url);
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
const rootDir = await resolveRootDir(scriptPath);
|
const rootDir = await resolveRootDir(scriptPath);
|
||||||
@@ -669,22 +727,35 @@ async function main() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForCondition(
|
try {
|
||||||
async () => {
|
await waitForCondition(
|
||||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
async () => {
|
||||||
token: authToken,
|
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||||
});
|
token: authToken,
|
||||||
|
});
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
detail?.data?.channel_status?.broker?.connected
|
detail?.data?.channel_status?.broker?.connected
|
||||||
|| detail?.data?.metadata?.broker_connected
|
|| detail?.data?.metadata?.broker_connected
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeoutMs: 90_000,
|
timeoutMs: 90_000,
|
||||||
message: "Gateway never established a live broker connection after install.",
|
message: "Gateway never established a live broker connection after install.",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostics = await readGatewayDiagnostics({
|
||||||
|
baseUrl,
|
||||||
|
authToken,
|
||||||
|
gatewayId,
|
||||||
|
containerName,
|
||||||
|
});
|
||||||
|
throw new Error([
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
const WebSocketImpl = await loadWebSocketImplementation();
|
const WebSocketImpl = await loadWebSocketImplementation();
|
||||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||||
@@ -707,11 +778,14 @@ async function main() {
|
|||||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||||
);
|
);
|
||||||
|
|
||||||
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
await waitForSocketMessage(
|
||||||
assert.equal(
|
streamMessages,
|
||||||
Boolean(readyMessage?.connected),
|
(message) => (
|
||||||
true,
|
message?.type === "gateway.stream.ready" && message?.connected === true
|
||||||
"Gateway stream became ready before the broker reported the gateway as connected."
|
) || (
|
||||||
|
message?.type === "presence.changed" && message?.status === "connected"
|
||||||
|
),
|
||||||
|
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
|
||||||
);
|
);
|
||||||
|
|
||||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||||
@@ -739,10 +813,24 @@ async function main() {
|
|||||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForSocketMessage(
|
await waitForCondition(
|
||||||
streamMessages,
|
async () => {
|
||||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||||
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
token: authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = Array.isArray(operations?.data)
|
||||||
|
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return operation?.status === "COMPLETED"
|
||||||
|
|| streamMessages.some((message) => (
|
||||||
|
message?.type === "task.updated"
|
||||||
|
&& Number(message?.operationId || 0) === operationId
|
||||||
|
&& message?.operation?.status === "COMPLETED"
|
||||||
|
));
|
||||||
|
},
|
||||||
|
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let operationSnapshot = null;
|
let operationSnapshot = null;
|
||||||
@@ -765,21 +853,6 @@ async function main() {
|
|||||||
throw new Error(diagnostic);
|
throw new Error(diagnostic);
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForCondition(
|
|
||||||
async () => {
|
|
||||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
|
||||||
token: authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const operation = Array.isArray(operations?.data)
|
|
||||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return operation?.status === "COMPLETED";
|
|
||||||
},
|
|
||||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitForSocketMessage(
|
await waitForSocketMessage(
|
||||||
streamMessages,
|
streamMessages,
|
||||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||||
|
|||||||
+16
-3
@@ -18,6 +18,7 @@ cd "$repo_root"
|
|||||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||||
project_suffix="$(date +%s)-$$"
|
project_suffix="$(date +%s)-$$"
|
||||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||||
|
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||||
|
|
||||||
log_dir=".tmp/ci-logs/$suite"
|
log_dir=".tmp/ci-logs/$suite"
|
||||||
mkdir -p "$log_dir"
|
mkdir -p "$log_dir"
|
||||||
@@ -84,6 +85,17 @@ composer_install() {
|
|||||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_ci_docker_subnet() {
|
||||||
|
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||||
|
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||||
|
subnet_third=$((checksum % 256))
|
||||||
|
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
status="$?"
|
status="$?"
|
||||||
collect_logs "$status"
|
collect_logs "$status"
|
||||||
@@ -103,7 +115,8 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
configure_ci_docker_subnet
|
||||||
|
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc '
|
docker compose $compose_files exec -T php1 sh -lc '
|
||||||
set -eu
|
set -eu
|
||||||
@@ -126,7 +139,7 @@ tar \
|
|||||||
--exclude='./.phpunit.cache' \
|
--exclude='./.phpunit.cache' \
|
||||||
--exclude='./build/logs' \
|
--exclude='./build/logs' \
|
||||||
-C services/nginx/app -cf - . \
|
-C services/nginx/app -cf - . \
|
||||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||||
tar \
|
tar \
|
||||||
@@ -135,7 +148,7 @@ tar \
|
|||||||
Dockerfile.coolify-api \
|
Dockerfile.coolify-api \
|
||||||
services/php/Dockerfile \
|
services/php/Dockerfile \
|
||||||
services/php/php-fpm-pool.conf \
|
services/php/php-fpm-pool.conf \
|
||||||
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||||
|
|
||||||
composer_install
|
composer_install
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||||
|
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
|
||||||
|
export const REQUIRED_MANIFEST_ARTIFACTS = [
|
||||||
|
"agent.php",
|
||||||
|
"lan-worker.php",
|
||||||
|
"auto-updater.php",
|
||||||
|
"docker-compose.gateway.yml",
|
||||||
|
"Dockerfile.edge-agent",
|
||||||
|
"Dockerfile.lan-worker",
|
||||||
|
"Dockerfile.auto-updater",
|
||||||
|
"gateway-launcher.sh",
|
||||||
|
"truckwash-edge-gateway-stack.service",
|
||||||
|
"truckwash-edge-agent.service",
|
||||||
|
];
|
||||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||||
"/edge-agent/install-token/status",
|
"/edge-agent/install-token/status",
|
||||||
|
"/edge-agent/artifacts/manifest.json",
|
||||||
"report_install_status",
|
"report_install_status",
|
||||||
'begin_install_phase "VERIFY_TOKEN"',
|
'begin_install_phase "VERIFY_TOKEN"',
|
||||||
|
'begin_install_phase "VERIFY_ARTIFACTS"',
|
||||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||||
'report_install_status "FAILED"',
|
'report_install_status "FAILED"',
|
||||||
|
"verify_manifest_artifact",
|
||||||
|
EXPECTED_INSTALL_VERSION,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function normalizeBaseUrl(url) {
|
export function normalizeBaseUrl(url) {
|
||||||
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
|
|||||||
name: "Ping",
|
name: "Ping",
|
||||||
url: `${normalizedBaseUrl}/ping`,
|
url: `${normalizedBaseUrl}/ping`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Artifact manifest",
|
||||||
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
||||||
|
artifactName: "manifest.json",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Agent PHP artifact",
|
name: "Agent PHP artifact",
|
||||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||||
|
artifactName: "agent.php",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Service unit artifact",
|
name: "Service unit artifact",
|
||||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||||
|
artifactName: "truckwash-edge-agent.service",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Installer script",
|
name: "Installer script",
|
||||||
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateArtifactManifestBody(body) {
|
||||||
|
const manifest = JSON.parse(String(body || ""));
|
||||||
|
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
|
||||||
|
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(manifest.artifacts)) {
|
||||||
|
throw new Error("Artifact manifest is missing artifacts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
|
||||||
|
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
|
||||||
|
if (missingArtifacts.length) {
|
||||||
|
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
|
||||||
|
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
|
||||||
|
if (!artifact) {
|
||||||
|
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
|
||||||
|
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||||
|
if (sha256 !== artifact.sha256) {
|
||||||
|
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
|
||||||
|
}
|
||||||
|
if (buffer.length !== artifact.bytes) {
|
||||||
|
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateInstallerScriptBody(body) {
|
export function validateInstallerScriptBody(body) {
|
||||||
const source = String(body || "");
|
const source = String(body || "");
|
||||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||||
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
|
|||||||
|
|
||||||
const checks = buildChecks(baseUrl, installToken);
|
const checks = buildChecks(baseUrl, installToken);
|
||||||
const results = [];
|
const results = [];
|
||||||
|
let artifactManifest = null;
|
||||||
|
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||||
const response = await fetch(check.url);
|
const response = await fetch(check.url);
|
||||||
const body = await response.text();
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
const result = {
|
const result = {
|
||||||
...check,
|
...check,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
bodyPreview: previewBody(body),
|
bodyPreview: previewBody(body.toString("utf8")),
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (check.name === "Artifact manifest") {
|
||||||
|
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
|
||||||
|
result.version = artifactManifest.version;
|
||||||
|
result.artifactCount = artifactManifest.artifacts.length;
|
||||||
|
}
|
||||||
|
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
|
||||||
|
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
|
||||||
|
}
|
||||||
if (check.name === "Installer script") {
|
if (check.name === "Installer script") {
|
||||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_STAGING_BASE_URL,
|
DEFAULT_STAGING_BASE_URL,
|
||||||
|
EXPECTED_INSTALL_VERSION,
|
||||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||||
buildChecks,
|
buildChecks,
|
||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
validateArtifactBodyAgainstManifest,
|
||||||
|
validateArtifactManifestBody,
|
||||||
validateInstallerScriptBody,
|
validateInstallerScriptBody,
|
||||||
} from "./staging-edge-gateway-smoke.mjs";
|
} from "./staging-edge-gateway-smoke.mjs";
|
||||||
|
|
||||||
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
|
|||||||
|
|
||||||
assert.deepEqual(checks.map((check) => check.url), [
|
assert.deepEqual(checks.map((check) => check.url), [
|
||||||
"https://api.truckwash.io:4433/ping",
|
"https://api.truckwash.io:4433/ping",
|
||||||
|
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
|
||||||
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validateArtifactManifestBody requires v3 install artifacts", () => {
|
||||||
|
const artifacts = [
|
||||||
|
"agent.php",
|
||||||
|
"lan-worker.php",
|
||||||
|
"auto-updater.php",
|
||||||
|
"docker-compose.gateway.yml",
|
||||||
|
"Dockerfile.edge-agent",
|
||||||
|
"Dockerfile.lan-worker",
|
||||||
|
"Dockerfile.auto-updater",
|
||||||
|
"gateway-launcher.sh",
|
||||||
|
"truckwash-edge-gateway-stack.service",
|
||||||
|
"truckwash-edge-agent.service",
|
||||||
|
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
|
||||||
|
|
||||||
|
const manifest = validateArtifactManifestBody(JSON.stringify({
|
||||||
|
version: EXPECTED_INSTALL_VERSION,
|
||||||
|
artifacts,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
|
||||||
|
const body = Buffer.from("hello");
|
||||||
|
const manifest = {
|
||||||
|
artifacts: [{
|
||||||
|
name: "agent.php",
|
||||||
|
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||||
|
bytes: body.length,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
|
||||||
|
});
|
||||||
|
|
||||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||||
const script = `
|
const script = `
|
||||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||||
|
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
|
||||||
report_install_status "FAILED"
|
report_install_status "FAILED"
|
||||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||||
|
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||||
|
verify_manifest_artifact
|
||||||
|
${EXPECTED_INSTALL_VERSION}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
|||||||
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
||||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||||
@@ -409,14 +409,20 @@ async function startContainer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (copyConfig) {
|
if (copyConfig) {
|
||||||
await runCommand("docker", [
|
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||||
"cp",
|
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
||||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
try {
|
||||||
`${containerName}:${containerConfigPath}`,
|
await runCommand("docker", [
|
||||||
], {
|
"cp",
|
||||||
cwd: rootDir,
|
configFilePath,
|
||||||
stdio: "inherit",
|
`${containerName}:${containerConfigPath}`,
|
||||||
});
|
], {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
await runCommand("docker", ["start", containerName], {
|
await runCommand("docker", ["start", containerName], {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
|
|||||||
Vendored
+58
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency(items, limit, mapper) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
results[index] = await mapper(items[index], index);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||||
|
const commands = Array.isArray(command?.payload?.commands)
|
||||||
|
? command.payload.commands
|
||||||
|
: Array.isArray(command?.commands)
|
||||||
|
? command.commands
|
||||||
|
: [];
|
||||||
|
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||||
|
|
||||||
|
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||||
|
const target = String(entry.target || entry.relay || "");
|
||||||
|
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||||
|
try {
|
||||||
|
const payload = await handler(entry, fetchImpl);
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
relayId,
|
||||||
|
relay_id: relayId,
|
||||||
|
ok: true,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
relayId,
|
||||||
|
relay_id: relayId,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||||
|
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
|||||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||||
case "SET_RELAY_STATE":
|
case "SET_RELAY_STATE":
|
||||||
return await setRelayState(command.payload || {}, fetchImpl);
|
return await setRelayState(command.payload || {}, fetchImpl);
|
||||||
|
case "BATCH_RELAY_STATUS":
|
||||||
|
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||||
|
case "BATCH_SET_RELAY_STATE":
|
||||||
|
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||||
case "RUN_UPDATE":
|
case "RUN_UPDATE":
|
||||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||||
case "UNINSTALL_AGENT":
|
case "UNINSTALL_AGENT":
|
||||||
|
|||||||
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
|||||||
assert.equal(switched.on, false);
|
assert.equal(switched.on, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||||
|
const fakeFetch = async (url) => {
|
||||||
|
const value = String(url);
|
||||||
|
if (value.includes("10.1.0.31")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { output: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("relay offline");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleAgentCommand({
|
||||||
|
commandType: "BATCH_SET_RELAY_STATE",
|
||||||
|
payload: {
|
||||||
|
batch_id: "batch-1",
|
||||||
|
commands: [
|
||||||
|
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||||
|
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, { fetchImpl: fakeFetch });
|
||||||
|
|
||||||
|
assert.equal(result.batch_id, "batch-1");
|
||||||
|
assert.equal(result.results.length, 2);
|
||||||
|
assert.equal(result.results[0].ok, true);
|
||||||
|
assert.equal(result.results[0].target, "MACHINE");
|
||||||
|
assert.equal(result.results[1].ok, false);
|
||||||
|
assert.match(result.results[1].error, /relay offline/);
|
||||||
|
});
|
||||||
|
|
||||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
|
|||||||
? async (_gatewayId, payload = {}) => payload
|
? async (_gatewayId, payload = {}) => payload
|
||||||
: async (gatewayId, payload = {}) =>
|
: async (gatewayId, payload = {}) =>
|
||||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||||
|
const ingestMachineSignal =
|
||||||
|
options.ingestMachineSignal ||
|
||||||
|
(authMode === "stub"
|
||||||
|
? async (_gatewayId, payload = {}) => payload
|
||||||
|
: async (gatewayId, payload = {}) =>
|
||||||
|
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||||
|
|
||||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||||
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === "MACHINE_SIGNAL") {
|
||||||
|
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||||
if (!sessionRecord) {
|
if (!sessionRecord) {
|
||||||
|
|||||||
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
|||||||
await broker.close();
|
await broker.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||||
|
const machineSignals = [];
|
||||||
|
const broker = createBrokerServer({
|
||||||
|
authMode: "stub",
|
||||||
|
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||||
|
ingestMachineSignal: async (gatewayId, payload) => {
|
||||||
|
machineSignals.push({ gatewayId, payload });
|
||||||
|
return { recorded: true, lane_id: payload.lane_id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const address = await broker.listen(0);
|
||||||
|
const port = address.port;
|
||||||
|
|
||||||
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||||
|
await new Promise((resolve) => agent.once("open", resolve));
|
||||||
|
|
||||||
|
agent.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "MACHINE_SIGNAL",
|
||||||
|
payload: {
|
||||||
|
lane_id: 3,
|
||||||
|
relay_id: "machine-relay",
|
||||||
|
component: "input",
|
||||||
|
channel: 0,
|
||||||
|
event: "input.toggle_on",
|
||||||
|
state: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||||
|
assert.equal(machineSignals[0].gatewayId, "701");
|
||||||
|
assert.deepEqual(machineSignals[0].payload, {
|
||||||
|
lane_id: 3,
|
||||||
|
relay_id: "machine-relay",
|
||||||
|
component: "input",
|
||||||
|
channel: 0,
|
||||||
|
event: "input.toggle_on",
|
||||||
|
state: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.terminate();
|
||||||
|
await broker.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||||
const broker = createBrokerServer({
|
const broker = createBrokerServer({
|
||||||
authMode: "stub",
|
authMode: "stub",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12452,3 +12452,875 @@
|
|||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
|
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
|
||||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
|
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
|
||||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
|
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
|
||||||
|
[Thu Jun 4 05:49:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41145) started
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Closing
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Accepted
|
||||||
|
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Closing
|
||||||
|
[Thu Jun 4 05:49:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45671) started
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Accepted
|
||||||
|
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Closing
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Accepted
|
||||||
|
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Closing
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Accepted
|
||||||
|
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Closing
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Accepted
|
||||||
|
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Closing
|
||||||
|
[Thu Jun 4 05:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33783) started
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Closing
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Accepted
|
||||||
|
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Closing
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Accepted
|
||||||
|
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Closing
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Accepted
|
||||||
|
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Closing
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Accepted
|
||||||
|
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Closing
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Accepted
|
||||||
|
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Closing
|
||||||
|
[Thu Jun 4 06:06:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36081) started
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Accepted
|
||||||
|
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Closing
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Accepted
|
||||||
|
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Closing
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Accepted
|
||||||
|
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Closing
|
||||||
|
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58454 Accepted
|
||||||
|
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58454 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Closing
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Accepted
|
||||||
|
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Closing
|
||||||
|
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Accepted
|
||||||
|
[Thu Jun 4 06:07:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Closing
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Accepted
|
||||||
|
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Closing
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Accepted
|
||||||
|
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Closing
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Accepted
|
||||||
|
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Closing
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Accepted
|
||||||
|
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Closing
|
||||||
|
[Thu Jun 4 06:18:18 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45139) started
|
||||||
|
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Accepted
|
||||||
|
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Closing
|
||||||
|
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40646 Accepted
|
||||||
|
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40646 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Closing
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Accepted
|
||||||
|
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Closing
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Accepted
|
||||||
|
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Closing
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Accepted
|
||||||
|
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Closing
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Accepted
|
||||||
|
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Closing
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Accepted
|
||||||
|
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Closing
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Accepted
|
||||||
|
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Closing
|
||||||
|
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Accepted
|
||||||
|
[Thu Jun 4 06:18:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40389) started
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Closing
|
||||||
|
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60916 Accepted
|
||||||
|
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60916 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Closing
|
||||||
|
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60964 Accepted
|
||||||
|
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60964 Closing
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Accepted
|
||||||
|
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Closing
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Accepted
|
||||||
|
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Closing
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Accepted
|
||||||
|
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Closing
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Accepted
|
||||||
|
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44515) started
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Accepted
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Closing
|
||||||
|
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34274 Accepted
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34274 Closing
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Accepted
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Closing
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Accepted
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Closing
|
||||||
|
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34290 Accepted
|
||||||
|
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34290 Closing
|
||||||
|
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34296 Accepted
|
||||||
|
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34296 Closing
|
||||||
|
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34304 Accepted
|
||||||
|
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34304 Closing
|
||||||
|
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34314 Accepted
|
||||||
|
[Fri Jun 12 12:04:20 2026] 127.0.0.1:34314 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35009) started
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Closing
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Accepted
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Closing
|
||||||
|
[Fri Jun 12 12:23:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33981) started
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Accepted
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Closing
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Accepted
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Closing
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Accepted
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43643) started
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Closing
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Accepted
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Closing
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Accepted
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Closing
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Accepted
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Closing
|
||||||
|
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59078 Accepted
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59078 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Closing
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Accepted
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Closing
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Accepted
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Closing
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Accepted
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Closing
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Accepted
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Accepted
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Closing
|
||||||
|
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59470 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59470 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Closing
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Accepted
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Closing
|
||||||
|
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Accepted
|
||||||
|
[Fri Jun 12 12:39:41 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:39:41 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Closing
|
||||||
|
[Fri Jun 12 12:41:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45327) started
|
||||||
|
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Accepted
|
||||||
|
[Fri Jun 12 12:41:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:41:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Closing
|
||||||
|
[Fri Jun 12 12:42:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44757) started
|
||||||
|
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Accepted
|
||||||
|
[Fri Jun 12 12:42:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:42:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Closing
|
||||||
|
[Fri Jun 12 12:43:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43731) started
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Accepted
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Closing
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Accepted
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Closing
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Accepted
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Closing
|
||||||
|
[Fri Jun 12 12:53:28 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41773) started
|
||||||
|
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Accepted
|
||||||
|
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Closing
|
||||||
|
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Accepted
|
||||||
|
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Closing
|
||||||
|
[Fri Jun 12 13:42:40 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45259) started
|
||||||
|
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Accepted
|
||||||
|
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Closing
|
||||||
|
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Accepted
|
||||||
|
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Closing
|
||||||
|
[Fri Jun 12 13:45:11 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43693) started
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Accepted
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Closing
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Accepted
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Closing
|
||||||
|
[Fri Jun 12 13:45:12 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37353) started
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Accepted
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Closing
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Accepted
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Closing
|
||||||
|
[Fri Jun 12 13:49:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:32781) started
|
||||||
|
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Accepted
|
||||||
|
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Closing
|
||||||
|
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Accepted
|
||||||
|
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Closing
|
||||||
|
[Fri Jun 12 13:49:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33723) started
|
||||||
|
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Accepted
|
||||||
|
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Closing
|
||||||
|
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Accepted
|
||||||
|
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Closing
|
||||||
|
[Fri Jun 12 13:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34549) started
|
||||||
|
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Accepted
|
||||||
|
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Closing
|
||||||
|
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59342 Accepted
|
||||||
|
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:49:58 2026] 127.0.0.1:59342 Closing
|
||||||
|
[Fri Jun 12 13:49:58 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36181) started
|
||||||
|
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Accepted
|
||||||
|
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Closing
|
||||||
|
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Accepted
|
||||||
|
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Closing
|
||||||
|
[Fri Jun 12 14:03:34 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40101) started
|
||||||
|
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Accepted
|
||||||
|
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Closing
|
||||||
|
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Accepted
|
||||||
|
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Closing
|
||||||
|
[Fri Jun 12 14:03:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33543) started
|
||||||
|
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Accepted
|
||||||
|
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Closing
|
||||||
|
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Accepted
|
||||||
|
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Closing
|
||||||
|
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46061) started
|
||||||
|
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Accepted
|
||||||
|
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Closing
|
||||||
|
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Accepted
|
||||||
|
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Closing
|
||||||
|
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34023) started
|
||||||
|
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Accepted
|
||||||
|
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Closing
|
||||||
|
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Accepted
|
||||||
|
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||||
|
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||||
|
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||||
|
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Closing
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class customer_order_product_policy
|
||||||
|
{
|
||||||
|
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||||
|
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||||
|
|
||||||
|
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||||
|
{
|
||||||
|
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||||
|
if ($message !== null) {
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||||
|
{
|
||||||
|
$context = self::loadOrderProductContext($orderId, $productId);
|
||||||
|
if ($context === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||||
|
? self::ONLY_TANKCLEANING_MESSAGE
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||||
|
{
|
||||||
|
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isTankCleaningProductRow(array $row): bool
|
||||||
|
{
|
||||||
|
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||||
|
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if ($orderId < 1 || $productId < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
o.id AS order_id,
|
||||||
|
o.customer_id AS customer_number,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
p.category AS product_category,
|
||||||
|
c.name AS category_name,
|
||||||
|
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN products p ON p.id = {$productId}
|
||||||
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
|
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||||
|
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||||
|
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||||
|
WHERE o.id = {$orderId}
|
||||||
|
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result || $result->num_rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||||
|
{
|
||||||
|
$haystack = strtolower(trim(
|
||||||
|
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||||
|
(string)($row['category_name'] ?? '')
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($terms as $term) {
|
||||||
|
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
|||||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||||
);
|
);
|
||||||
|
|
||||||
return $collected_order_invoices->asArray();
|
$result = $collected_order_invoices->asArray();
|
||||||
|
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||||
|
if ($transfer_metrics !== null) {
|
||||||
|
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
|
|||||||
return match ($endpoint) {
|
return match ($endpoint) {
|
||||||
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
||||||
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
|
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
|
||||||
|
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
|
||||||
|
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
|
||||||
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
|
|||||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function handleBatchGetStates(int $departmentId, array $data): array
|
||||||
|
{
|
||||||
|
$requests = [];
|
||||||
|
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
|
||||||
|
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
|
||||||
|
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
|
||||||
|
if ($relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$requests[] = [
|
||||||
|
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function handleBatchSetSwitch(int $departmentId, array $data): array
|
||||||
|
{
|
||||||
|
$requests = [];
|
||||||
|
foreach ((array)($data['commands'] ?? []) as $entry) {
|
||||||
|
if (!is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
|
||||||
|
if ($relayId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$requests[] = [
|
||||||
|
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'on' => (bool)($entry['on'] ?? false),
|
||||||
|
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $status
|
* @param array<string,mixed> $status
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
|
|||||||
@@ -2036,8 +2036,7 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function rowIsTankCleaningProduct(array $row): bool
|
private function rowIsTankCleaningProduct(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['product_category'] ?? 0) === 5
|
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isIncludedOrderItem(array $row): bool
|
private function isIncludedOrderItem(array $row): bool
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class limited_backoffice_exception extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
private readonly int $statusCode = 400,
|
||||||
|
private readonly ?array $payload = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function statusCode(): int
|
||||||
|
{
|
||||||
|
return $this->statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array|string
|
||||||
|
{
|
||||||
|
return $this->payload ?? $this->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class limited_backoffice_schema_bootstrap
|
||||||
|
{
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
public static function ensureTables(): void
|
||||||
|
{
|
||||||
|
if (self::$initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`managed_group_id` INT NOT NULL,
|
||||||
|
`role_key` VARCHAR(64) NOT NULL,
|
||||||
|
`department_ids` LONGTEXT NOT NULL,
|
||||||
|
`created_by_user_id` INT NOT NULL,
|
||||||
|
`updated_by_user_id` INT NULL,
|
||||||
|
`deactivated_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||||
|
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||||
|
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||||
|
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ class router
|
|||||||
}
|
}
|
||||||
// Regex
|
// Regex
|
||||||
$route = str_replace('/', '\/', $route);
|
$route = str_replace('/', '\/', $route);
|
||||||
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
|
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
|
||||||
if (preg_match('/^' . $route . '$/', $this->url)) {
|
if (preg_match('/^' . $route . '$/', $this->url)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,11 +196,104 @@ class slack implements notification_i
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
|
||||||
|
*
|
||||||
|
* @return array{configured:bool,sent:bool,message:string}
|
||||||
|
*/
|
||||||
|
public function test_internal_department_goal_progress_webhook(): array
|
||||||
|
{
|
||||||
|
$webhook = $this->get_internal_department_goal_progress_webhook_url();
|
||||||
|
if ($webhook === '') {
|
||||||
|
return [
|
||||||
|
'configured' => false,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack internal department goal progress webhook URL is not configured.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->send_webhook_message(
|
||||||
|
$this->format_internal_department_goal_progress_test(),
|
||||||
|
$webhook
|
||||||
|
);
|
||||||
|
$sent = $this->is_webhook_send_successful($result);
|
||||||
|
|
||||||
|
self::add_log($sent
|
||||||
|
? 'Slack internal department goal progress test webhook sent successfully.'
|
||||||
|
: 'Slack internal department goal progress test webhook failed.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => $sent,
|
||||||
|
'message' => $sent
|
||||||
|
? 'Slack test message sent successfully.'
|
||||||
|
: 'Slack test message failed.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function get_customer_registration_webhook_url(): string
|
protected function get_customer_registration_webhook_url(): string
|
||||||
{
|
{
|
||||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function get_internal_department_goal_progress_webhook_url(): string
|
||||||
|
{
|
||||||
|
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function get_internal_department_ids(): array
|
||||||
|
{
|
||||||
|
return $this->getConfig()->internal_department_ids->getDepartmentIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $department_ids
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
|
||||||
|
{
|
||||||
|
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
|
||||||
|
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
|
||||||
|
|
||||||
|
return $this->get_internal_department_goal_progress_config();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_internal_department_goal_progress_config(): array
|
||||||
|
{
|
||||||
|
$departments = (new departments_o())->getFieldsWhere(
|
||||||
|
[
|
||||||
|
'visible' => 1,
|
||||||
|
'archived' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'order_priority',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
usort($departments, static function (array $a, array $b): int {
|
||||||
|
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
|
||||||
|
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
|
||||||
|
'internal_department_ids' => $this->get_internal_department_ids(),
|
||||||
|
'departments' => array_map(static function (array $department): array {
|
||||||
|
return [
|
||||||
|
'id' => (int)$department['id'],
|
||||||
|
'name' => (string)$department['name'],
|
||||||
|
'order_priority' => (int)$department['order_priority'],
|
||||||
|
];
|
||||||
|
}, $departments),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function is_webhook_send_successful(string $result): bool
|
public function is_webhook_send_successful(string $result): bool
|
||||||
{
|
{
|
||||||
return !str_starts_with($result, 'Failed to send message:');
|
return !str_starts_with($result, 'Failed to send message:');
|
||||||
@@ -230,4 +323,10 @@ class slack implements notification_i
|
|||||||
return "*Truck Wash Slack test*\n"
|
return "*Truck Wash Slack test*\n"
|
||||||
. "Customer registration notifications are configured correctly.";
|
. "Customer registration notifications are configured correctly.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function format_internal_department_goal_progress_test(): string
|
||||||
|
{
|
||||||
|
return "*Truck Wash Slack test*\n"
|
||||||
|
. "Internal department goal progress notifications are configured correctly.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-5
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
|
|||||||
* @throws Exception If the request fails
|
* @throws Exception If the request fails
|
||||||
*/
|
*/
|
||||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
||||||
|
{
|
||||||
|
$this->add_orders($invoiceDraftId, [$order], $currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add many orders to a draft invoice and flush their lines in batches.
|
||||||
|
*
|
||||||
|
* @param orders_o[] $orders
|
||||||
|
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||||
|
* @throws Exception If the request fails
|
||||||
|
*/
|
||||||
|
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
||||||
{
|
{
|
||||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||||
// Check if the order includes any items that should be included in the invoice
|
$orders_with_invoice_lines = 0;
|
||||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
|
||||||
|
foreach ( $orders as $order ) {
|
||||||
|
if (!$order instanceof orders_o) {
|
||||||
|
throw new Exception('Order payload must contain orders_o instances');
|
||||||
|
}
|
||||||
|
// Check if the order includes any items that should be included in the invoice
|
||||||
|
if ($order->getIncludeInInvoiceCount() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders_with_invoice_lines++;
|
||||||
// Add the transaction header (Timestamp, department, etc.)
|
// Add the transaction header (Timestamp, department, etc.)
|
||||||
$draftInvoice->addNewTransactionHeader($order);
|
$draftInvoice->addNewTransactionHeader($order);
|
||||||
// Add the order lines
|
// Add the order lines
|
||||||
$draftInvoice->addOrderItemLines($order);
|
$draftInvoice->addOrderItemLines($order);
|
||||||
// Add an empty line, so the invoice is not empty
|
// Add an empty line, so the invoice is not empty
|
||||||
$draftInvoice->addTextLine('');
|
$draftInvoice->addTextLine('');
|
||||||
// Save the draft invoice lines
|
|
||||||
$draftInvoice->addLines();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_count' => count($orders),
|
||||||
|
'orders_with_invoice_lines' => $orders_with_invoice_lines,
|
||||||
|
...$metrics,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
|
|||||||
$draft_invoice->addLines();
|
$draft_invoice->addLines();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use objects\orders_o;
|
|||||||
|
|
||||||
class economic_invoice_draft
|
class economic_invoice_draft
|
||||||
{
|
{
|
||||||
|
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Economic draftInvoiceNumber
|
* The Economic draftInvoiceNumber
|
||||||
* @var int $draft_invoice_number
|
* @var int $draft_invoice_number
|
||||||
@@ -110,13 +112,55 @@ class economic_invoice_draft
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the lines to the draft invoice
|
* Add the lines to the draft invoice.
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function addLines(): void
|
public function addLines(): void
|
||||||
|
{
|
||||||
|
$this->flushLinesInBatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add queued draft lines using chunked requests.
|
||||||
|
*
|
||||||
|
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||||
|
*/
|
||||||
|
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
||||||
|
{
|
||||||
|
$lines = array_values($this->draft_lines);
|
||||||
|
$line_count = count($lines);
|
||||||
|
if ($line_count === 0) {
|
||||||
|
return [
|
||||||
|
'line_count' => 0,
|
||||||
|
'batch_count' => 0,
|
||||||
|
'batch_sizes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch_size = max(1, $batch_size);
|
||||||
|
$batch_sizes = [];
|
||||||
|
foreach (array_chunk($lines, $batch_size) as $batch) {
|
||||||
|
$this->sendDraftLines($batch);
|
||||||
|
$batch_sizes[] = count($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->draft_lines = [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'line_count' => $line_count,
|
||||||
|
'batch_count' => count($batch_sizes),
|
||||||
|
'batch_sizes' => $batch_sizes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingLineCount(): int
|
||||||
|
{
|
||||||
|
return count($this->draft_lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
{
|
{
|
||||||
$economic = new economic();
|
$economic = new economic();
|
||||||
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+44
-3
@@ -6,6 +6,7 @@ use Exception;
|
|||||||
|
|
||||||
class edge_gateway_agent_artifact_locator
|
class edge_gateway_agent_artifact_locator
|
||||||
{
|
{
|
||||||
|
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
|
||||||
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||||
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||||
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||||
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
|
|||||||
|
|
||||||
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||||
|
|
||||||
|
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
|
||||||
|
$candidateDirectories[] = $directory;
|
||||||
|
}
|
||||||
|
|
||||||
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||||
if ($mountedArtifactDirectory !== null) {
|
if ($mountedArtifactDirectory !== null) {
|
||||||
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
||||||
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
|
|||||||
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
|
||||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
$candidateDirectories[] = $directory;
|
||||||
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
}
|
||||||
|
|
||||||
$paths = [];
|
$paths = [];
|
||||||
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||||
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
|
|||||||
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private static function edgeAgentBuildDirectories(string $basePath): array
|
||||||
|
{
|
||||||
|
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||||
|
$directories = [
|
||||||
|
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||||
|
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||||
|
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||||
|
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (DIRECTORY_SEPARATOR === '/') {
|
||||||
|
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||||
|
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(
|
||||||
|
static fn(string $directory): string => self::normalizePath($directory),
|
||||||
|
$directories
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private static function legacyPhpAgentDirectories(string $basePath): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||||
|
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||||
|
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private static function mountedArtifactDirectory(): ?string
|
private static function mountedArtifactDirectory(): ?string
|
||||||
{
|
{
|
||||||
if (DIRECTORY_SEPARATOR !== '/') {
|
if (DIRECTORY_SEPARATOR !== '/') {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Exception;
|
|||||||
class edge_gateway_install_service
|
class edge_gateway_install_service
|
||||||
{
|
{
|
||||||
private const ARTIFACTS = [
|
private const ARTIFACTS = [
|
||||||
|
'manifest.json' => 'application/json; charset=utf-8',
|
||||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
@@ -26,7 +27,7 @@ class edge_gateway_install_service
|
|||||||
|
|
||||||
public function buildInstallScript(string $plainToken): string
|
public function buildInstallScript(string $plainToken): string
|
||||||
{
|
{
|
||||||
return $this->manager()->buildInstallScript($plainToken);
|
return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,13 +43,17 @@ class edge_gateway_install_service
|
|||||||
*/
|
*/
|
||||||
public function readArtifact(string $fileName): string
|
public function readArtifact(string $fileName): string
|
||||||
{
|
{
|
||||||
|
if ($fileName === 'manifest.json') {
|
||||||
|
return $this->buildManifest();
|
||||||
|
}
|
||||||
|
|
||||||
$path = $this->artifactPath($fileName);
|
$path = $this->artifactPath($fileName);
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
if ($contents === false) {
|
if ($contents === false) {
|
||||||
throw new Exception('Unable to read edge agent artifact');
|
throw new Exception('Unable to read edge agent artifact');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $contents;
|
return $this->normalizeLineEndings($contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function contentType(string $fileName): string
|
public function contentType(string $fileName): string
|
||||||
@@ -66,15 +71,55 @@ class edge_gateway_install_service
|
|||||||
*/
|
*/
|
||||||
public function artifactPath(string $fileName): string
|
public function artifactPath(string $fileName): string
|
||||||
{
|
{
|
||||||
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
|
||||||
throw new Exception('Unknown edge agent artifact');
|
throw new Exception('Unknown edge agent artifact');
|
||||||
}
|
}
|
||||||
|
|
||||||
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function buildManifest(): string
|
||||||
|
{
|
||||||
|
$artifacts = [];
|
||||||
|
foreach (self::ARTIFACTS as $fileName => $contentType) {
|
||||||
|
if ($fileName === 'manifest.json') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->artifactPath($fileName);
|
||||||
|
$sha256 = hash_file('sha256', $path);
|
||||||
|
$bytes = filesize($path);
|
||||||
|
if ($sha256 === false || $bytes === false) {
|
||||||
|
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$artifacts[] = [
|
||||||
|
'name' => $fileName,
|
||||||
|
'sha256' => $sha256,
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'schema_version' => 1,
|
||||||
|
'package' => 'truckwash-edge-agent',
|
||||||
|
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
|
||||||
|
'generated_at' => gmdate('c'),
|
||||||
|
'artifacts' => $artifacts,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
private function manager(): edge_gateway_manager
|
private function manager(): edge_gateway_manager
|
||||||
{
|
{
|
||||||
return $this->manager ?? new edge_gateway_manager();
|
return $this->manager ?? new edge_gateway_manager();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeLineEndings(string $contents): string
|
||||||
|
{
|
||||||
|
return str_replace(["\r\n", "\r"], "\n", $contents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
global $db;
|
$pdo = self::pdo();
|
||||||
|
|
||||||
$queries = [
|
$queries = [
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
|
|||||||
INDEX idx_edge_gateway_command_type (command_type)
|
INDEX idx_edge_gateway_command_type (command_type)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
gateway_id INT NOT NULL,
|
||||||
|
department_id INT NOT NULL,
|
||||||
|
lane_id INT NOT NULL,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
relay_id VARCHAR(255) NOT NULL,
|
||||||
|
device_id VARCHAR(255) NULL,
|
||||||
|
local_ip VARCHAR(64) NULL,
|
||||||
|
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
|
||||||
|
expected_state TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
last_attempted_at DATETIME NULL,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
metadata_json JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
|
||||||
|
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
|
||||||
|
INDEX idx_edge_gateway_expected_relay_department (department_id),
|
||||||
|
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
gateway_id INT NOT NULL,
|
gateway_id INT NOT NULL,
|
||||||
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($queries as $sql) {
|
foreach ($queries as $sql) {
|
||||||
$db->query($sql);
|
$pdo->exec($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
||||||
|
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||||
|
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
|
||||||
|
|
||||||
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
|
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
|
||||||
|
|
||||||
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
||||||
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
{
|
{
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (self::tableHasColumn($table, $column)) {
|
if (self::tableHasColumn($table, $column)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||||
}
|
}
|
||||||
|
|
||||||
$db->query(
|
self::pdo()->exec(
|
||||||
"ALTER TABLE `$table`
|
"ALTER TABLE `$table`
|
||||||
ADD COLUMN `$column` $definition"
|
ADD COLUMN `$column` $definition"
|
||||||
);
|
);
|
||||||
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
|
|||||||
string $definition,
|
string $definition,
|
||||||
?string $afterColumn = null
|
?string $afterColumn = null
|
||||||
): void {
|
): void {
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
||||||
|
|
||||||
$db->query(
|
self::pdo()->exec(
|
||||||
"ALTER TABLE `$table`
|
"ALTER TABLE `$table`
|
||||||
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
||||||
);
|
);
|
||||||
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
private static function tableHasColumn(string $table, string $column): bool
|
private static function tableHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
global $db;
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
|
||||||
|
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||||
$table = $db->escape_string($table);
|
|
||||||
$column = $db->escape_string($column);
|
|
||||||
$database = $db->escape_string($db->getDatabase());
|
|
||||||
|
|
||||||
$result = $db->query(
|
|
||||||
"SELECT COUNT(*) AS c
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = '$database'
|
|
||||||
AND TABLE_NAME = '$table'
|
|
||||||
AND COLUMN_NAME = '$column'"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$result) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
$statement = self::pdo()->prepare(
|
||||||
|
"SELECT COUNT(*) AS c
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table
|
||||||
|
AND COLUMN_NAME = :column"
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
':table' => $table,
|
||||||
|
':column' => $column,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
return ((int)($row['c'] ?? 0)) > 0;
|
return ((int)($row['c'] ?? 0)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function syncOperationTypeColumns(): void
|
private static function syncOperationTypeColumns(): void
|
||||||
{
|
{
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
||||||
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$db->query(
|
$pdo = self::pdo();
|
||||||
|
$pdo->exec(
|
||||||
"UPDATE edge_gateway_operations
|
"UPDATE edge_gateway_operations
|
||||||
SET type = operation_type
|
SET type = operation_type
|
||||||
WHERE operation_type IS NOT NULL
|
WHERE operation_type IS NOT NULL
|
||||||
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
||||||
);
|
);
|
||||||
|
|
||||||
$db->query(
|
$pdo->exec(
|
||||||
"UPDATE edge_gateway_operations
|
"UPDATE edge_gateway_operations
|
||||||
SET operation_type = type
|
SET operation_type = type
|
||||||
WHERE type IS NOT NULL
|
WHERE type IS NOT NULL
|
||||||
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
|
|||||||
AND (operation_type IS NULL OR operation_type = '')"
|
AND (operation_type IS NULL OR operation_type = '')"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function pdo(): \PDO
|
||||||
|
{
|
||||||
|
$envConfig = self::databaseConfigFromEnvironment();
|
||||||
|
if ($envConfig !== null) {
|
||||||
|
return self::connectPdo($envConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists(db::class, false)) {
|
||||||
|
$dbClassPath = __DIR__ . '/../../../classes/db.php';
|
||||||
|
if (is_file($dbClassPath)) {
|
||||||
|
require_once $dbClassPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists(db::class)) {
|
||||||
|
throw new \RuntimeException('Database connection helper is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return db::getPDO();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{host:string,user:string,password:string,database:string,port:int}|null
|
||||||
|
*/
|
||||||
|
private static function databaseConfigFromEnvironment(): ?array
|
||||||
|
{
|
||||||
|
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
|
||||||
|
if ($target !== 'debug') {
|
||||||
|
$target = 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = self::databaseEnvValue('HOST', $target);
|
||||||
|
$user = self::databaseEnvValue('USER', $target);
|
||||||
|
$database = self::databaseEnvValue('DATABASE', $target);
|
||||||
|
if ($host === '' || $user === '' || $database === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => $host,
|
||||||
|
'user' => $user,
|
||||||
|
'password' => self::databaseEnvValue('PASSWORD', $target),
|
||||||
|
'database' => $database,
|
||||||
|
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function databaseEnvValue(string $key, string $target): string
|
||||||
|
{
|
||||||
|
$liveValue = self::envString('CONFIG_DB_' . $key);
|
||||||
|
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
|
||||||
|
|
||||||
|
if ($target === 'debug' && $debugValue !== '') {
|
||||||
|
return $debugValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $liveValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function envString(string $key): string
|
||||||
|
{
|
||||||
|
$value = getenv($key);
|
||||||
|
if ($value === false || $value === null) {
|
||||||
|
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{host:string,user:string,password:string,database:string,port:int} $config
|
||||||
|
*/
|
||||||
|
private static function connectPdo(array $config): \PDO
|
||||||
|
{
|
||||||
|
$port = $config['port'] > 0 ? $config['port'] : 3306;
|
||||||
|
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
|
||||||
|
|
||||||
|
return new \PDO($dsn, $config['user'], $config['password'], [
|
||||||
|
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||||
|
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||||
|
\PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
|
|||||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||||
|
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
|
||||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||||
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
|
|||||||
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
|
||||||
|
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
|
||||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
||||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
||||||
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
|
|||||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
||||||
|
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
|
||||||
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||||
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
|
|||||||
$response->error('Missing token', 400);
|
$response->error('Missing token', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success($this->install()->verifyInstallToken($token));
|
try {
|
||||||
|
$response->success($this->install()->verifyInstallToken($token));
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentInstallTokenStatus(): void
|
private function handleAgentInstallTokenStatus(): void
|
||||||
@@ -407,17 +415,21 @@ class edgeGatewaysRoute
|
|||||||
self::requireParameters(['token', 'status']);
|
self::requireParameters(['token', 'status']);
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
$response->success($this->registry()->reportInstallTokenStatus(
|
try {
|
||||||
(string)$payload['token'],
|
$response->success($this->registry()->reportInstallTokenStatus(
|
||||||
[
|
(string)$payload['token'],
|
||||||
'status' => (string)$payload['status'],
|
[
|
||||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
'status' => (string)$payload['status'],
|
||||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||||
]
|
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||||
));
|
]
|
||||||
|
));
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderArtifact(string $fileName): void
|
private function renderArtifact(string $fileName): void
|
||||||
@@ -439,15 +451,19 @@ class edgeGatewaysRoute
|
|||||||
self::requireParameters(['token']);
|
self::requireParameters(['token']);
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
$response->success(
|
try {
|
||||||
$this->registry()->claimGateway(
|
$response->success(
|
||||||
(string)$payload['token'],
|
$this->registry()->claimGateway(
|
||||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
(string)$payload['token'],
|
||||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||||
),
|
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||||
201
|
),
|
||||||
);
|
201
|
||||||
|
);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentHeartbeat(): void
|
private function handleAgentHeartbeat(): void
|
||||||
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleAgentExpectedRelayStates(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
|
||||||
|
$gatewayId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAgentRelayStateResults(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
|
||||||
|
$response->success($this->manager()->recordExpectedRelayStateResults(
|
||||||
|
$gatewayId,
|
||||||
|
$this->requireAgentToken($payload),
|
||||||
|
$results
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private function handleAgentPresence(): void
|
private function handleAgentPresence(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleBrokerSelfserveMachineSignal(): void
|
||||||
|
{
|
||||||
|
global /** @var response $response */ $response;
|
||||||
|
$this->requireBrokerSecret();
|
||||||
|
$gatewayId = (int)$this->fromRoute('id');
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
|
||||||
|
$response->success($result, !empty($result['recorded']) ? 201 : 202);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function handleBrokerBrowserStreamValidate(): void
|
private function handleBrokerBrowserStreamValidate(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
|
|||||||
@@ -148,6 +148,25 @@ class selfserve_machine_signal
|
|||||||
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
||||||
$departmentId = (int)$gateway->department_id->value();
|
$departmentId = (int)$gateway->department_id->value();
|
||||||
|
|
||||||
|
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
|
||||||
|
{
|
||||||
|
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
|
||||||
|
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
|
||||||
|
{
|
||||||
return $this->recordCloudShellySignal(
|
return $this->recordCloudShellySignal(
|
||||||
$departmentId,
|
$departmentId,
|
||||||
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
||||||
|
|||||||
@@ -360,9 +360,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$allowedServices = $this->normalizeServiceNames(
|
$allowedServices = $this->normalizeServiceNames(
|
||||||
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
||||||
);
|
);
|
||||||
$machineAvailable = array_key_exists('machine_available', $metadata)
|
$machineWashEnabled = $this->isMachineWashEnabled();
|
||||||
|
if (!$machineWashEnabled) {
|
||||||
|
$allowedServices = $this->withoutMachineService($allowedServices);
|
||||||
|
}
|
||||||
|
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
|
||||||
? (bool)$metadata['machine_available']
|
? (bool)$metadata['machine_available']
|
||||||
: ($lane->exists() && !empty($lane->relay_machine_id->value()));
|
: ($lane->exists() && !empty($lane->relay_machine_id->value())));
|
||||||
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
|
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
|
||||||
? (bool)$metadata['all_visible_questions_answered']
|
? (bool)$metadata['all_visible_questions_answered']
|
||||||
: true;
|
: true;
|
||||||
@@ -408,6 +412,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'events' => $events,
|
'events' => $events,
|
||||||
'allowed_services' => $allowedServices,
|
'allowed_services' => $allowedServices,
|
||||||
'machine_available' => $machineAvailable,
|
'machine_available' => $machineAvailable,
|
||||||
|
'machine_wash_enabled' => $machineWashEnabled,
|
||||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||||
'allowed' => (bool)$session->allowed->value(),
|
'allowed' => (bool)$session->allowed->value(),
|
||||||
'config_version_id' => $metadata['config_version_id'] ?? null,
|
'config_version_id' => $metadata['config_version_id'] ?? null,
|
||||||
@@ -687,7 +692,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$machineAvailable = !empty($lane->relay_machine_id->value());
|
$machineWashEnabled = $this->isMachineWashEnabled();
|
||||||
|
if (!$machineWashEnabled) {
|
||||||
|
$allowedServices = $this->withoutMachineService($allowedServices);
|
||||||
|
}
|
||||||
|
|
||||||
|
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
|
||||||
$allVisibleQuestionsAnswered = true;
|
$allVisibleQuestionsAnswered = true;
|
||||||
foreach ($visibleQuestions as $question) {
|
foreach ($visibleQuestions as $question) {
|
||||||
if ($question['answer'] === null) {
|
if ($question['answer'] === null) {
|
||||||
@@ -726,8 +736,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'tasks' => $visibleTasks,
|
'tasks' => $visibleTasks,
|
||||||
'allowed_services' => $allowedServices,
|
'allowed_services' => $allowedServices,
|
||||||
'machine_available' => $machineAvailable,
|
'machine_available' => $machineAvailable,
|
||||||
|
'machine_wash_enabled' => $machineWashEnabled,
|
||||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||||
'allowed' => $machineAllowed,
|
'allowed' => $machineAllowed,
|
||||||
|
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
|
||||||
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
|
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
|
||||||
'config_source' => $configSource,
|
'config_source' => $configSource,
|
||||||
'evaluation_trace' => [
|
'evaluation_trace' => [
|
||||||
@@ -826,6 +838,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'tasks' => $snapshot['tasks'],
|
'tasks' => $snapshot['tasks'],
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'machine_available' => $snapshot['machine_available'],
|
'machine_available' => $snapshot['machine_available'],
|
||||||
|
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
|
||||||
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
||||||
'allowed' => $snapshot['allowed'],
|
'allowed' => $snapshot['allowed'],
|
||||||
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
||||||
@@ -3310,6 +3323,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return [
|
return [
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'machine_available' => (bool)$snapshot['machine_available'],
|
'machine_available' => (bool)$snapshot['machine_available'],
|
||||||
|
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
||||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||||
@@ -3608,6 +3622,27 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function isMachineWashEnabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (new selfserve())->config->machine_wash_enabled->isTrue();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $services
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
protected function withoutMachineService(array $services): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->normalizeServiceNames($services),
|
||||||
|
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
protected function taskUsesMachineControls(array $task): bool
|
protected function taskUsesMachineControls(array $task): bool
|
||||||
{
|
{
|
||||||
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace modules\selfserve\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class selfserve_machine_wash_enabled_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'selfserve',
|
||||||
|
'machine_wash_enabled',
|
||||||
|
'bool',
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
'Whether machine wash is available in customer-facing self-serve flows',
|
||||||
|
'1',
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace modules\selfserve;
|
namespace modules\selfserve;
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
||||||
|
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_enabled_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
|
||||||
|
|
||||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
||||||
use modules\selfserve\config\selfserve_enabled_c;
|
use modules\selfserve\config\selfserve_enabled_c;
|
||||||
|
use modules\selfserve\config\selfserve_machine_wash_enabled_c;
|
||||||
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
|
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
|
||||||
use modules\selfserve\config\selfserve_minute_product_c;
|
use modules\selfserve\config\selfserve_minute_product_c;
|
||||||
use traits\module_config_t;
|
use traits\module_config_t;
|
||||||
@@ -21,6 +23,11 @@ class selfserve_c
|
|||||||
* @var selfserve_enabled_c $enabled
|
* @var selfserve_enabled_c $enabled
|
||||||
*/
|
*/
|
||||||
public selfserve_enabled_c $enabled;
|
public selfserve_enabled_c $enabled;
|
||||||
|
/**
|
||||||
|
* Whether machine wash is available in customer-facing self-serve flows
|
||||||
|
* @var selfserve_machine_wash_enabled_c $machine_wash_enabled
|
||||||
|
*/
|
||||||
|
public selfserve_machine_wash_enabled_c $machine_wash_enabled;
|
||||||
/**
|
/**
|
||||||
* The product ID used for minute-based self-serve billing
|
* The product ID used for minute-based self-serve billing
|
||||||
* @var selfserve_minute_product_c $minute_product
|
* @var selfserve_minute_product_c $minute_product
|
||||||
@@ -42,11 +49,13 @@ class selfserve_c
|
|||||||
$this->setupConfig('selfserve');
|
$this->setupConfig('selfserve');
|
||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
selfserve_enabled_c::class,
|
selfserve_enabled_c::class,
|
||||||
|
selfserve_machine_wash_enabled_c::class,
|
||||||
selfserve_minute_product_c::class,
|
selfserve_minute_product_c::class,
|
||||||
selfserve_machine_wash_minutes_included_c::class,
|
selfserve_machine_wash_minutes_included_c::class,
|
||||||
selfserve_dynamic_image_size_c::class
|
selfserve_dynamic_image_size_c::class
|
||||||
]);
|
]);
|
||||||
$this->enabled = new selfserve_enabled_c();
|
$this->enabled = new selfserve_enabled_c();
|
||||||
|
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
|
||||||
$this->minute_product = new selfserve_minute_product_c();
|
$this->minute_product = new selfserve_minute_product_c();
|
||||||
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
||||||
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace slack\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class slack_internal_department_goal_progress_webhook_url_c
|
||||||
|
{
|
||||||
|
use module_config_variable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Slack',
|
||||||
|
'internal_department_goal_progress_webhook_url',
|
||||||
|
'string',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
'Slack webhook URL used for internal department goal progress notifications',
|
||||||
|
'https://hooks.slack.com/services/...',
|
||||||
|
true,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace slack\config;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use traits\module_config_variable;
|
||||||
|
|
||||||
|
class slack_internal_department_ids_c
|
||||||
|
{
|
||||||
|
use module_config_variable {
|
||||||
|
setVariableValue as private traitSetVariableValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
self::setupConfigVariable(
|
||||||
|
'Slack',
|
||||||
|
'internal_department_ids',
|
||||||
|
'string',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
'JSON encoded department IDs considered internal departments for Slack goal progress notifications',
|
||||||
|
'[1,2,3]',
|
||||||
|
false,
|
||||||
|
'[]'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDepartmentIds(): array
|
||||||
|
{
|
||||||
|
return self::normalizeDepartmentIds($this->getVariableValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function setVariableValue(mixed $value): void
|
||||||
|
{
|
||||||
|
$this->traitSetVariableValue(json_encode(
|
||||||
|
self::normalizeDepartmentIds($value),
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeDepartmentIds(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$value = $decoded;
|
||||||
|
} else {
|
||||||
|
$value = array_filter(array_map('trim', explode(',', $value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$departmentIds = [];
|
||||||
|
foreach ($value as $departmentId) {
|
||||||
|
$departmentId = (int)$departmentId;
|
||||||
|
if ($departmentId > 0) {
|
||||||
|
$departmentIds[] = $departmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$departmentIds = array_values(array_unique($departmentIds));
|
||||||
|
sort($departmentIds);
|
||||||
|
|
||||||
|
return $departmentIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
namespace slack;
|
namespace slack;
|
||||||
|
|
||||||
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
|
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
|
||||||
|
require_once WD . '/modules/slack/config/slack_internal_department_goal_progress_webhook_url_c.php';
|
||||||
|
require_once WD . '/modules/slack/config/slack_internal_department_ids_c.php';
|
||||||
|
|
||||||
use slack\config\slack_customer_registration_webhook_url_c;
|
use slack\config\slack_customer_registration_webhook_url_c;
|
||||||
|
use slack\config\slack_internal_department_goal_progress_webhook_url_c;
|
||||||
|
use slack\config\slack_internal_department_ids_c;
|
||||||
use traits\module_config_t;
|
use traits\module_config_t;
|
||||||
|
|
||||||
class slack_c
|
class slack_c
|
||||||
@@ -12,14 +16,20 @@ class slack_c
|
|||||||
use module_config_t;
|
use module_config_t;
|
||||||
|
|
||||||
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
|
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
|
||||||
|
public slack_internal_department_goal_progress_webhook_url_c $internal_department_goal_progress_webhook_url;
|
||||||
|
public slack_internal_department_ids_c $internal_department_ids;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->setupConfig('Slack');
|
$this->setupConfig('Slack');
|
||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
slack_customer_registration_webhook_url_c::class,
|
slack_customer_registration_webhook_url_c::class,
|
||||||
|
slack_internal_department_goal_progress_webhook_url_c::class,
|
||||||
|
slack_internal_department_ids_c::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
|
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
|
||||||
|
$this->internal_department_goal_progress_webhook_url = new slack_internal_department_goal_progress_webhook_url_c();
|
||||||
|
$this->internal_department_ids = new slack_internal_department_ids_c();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $closed_at;
|
public object_property $closed_at;
|
||||||
public int $economic_wash_subscription_user_id = 1857;
|
public int $economic_wash_subscription_user_id = 1857;
|
||||||
|
private ?array $last_economic_transfer_metrics = null;
|
||||||
/**
|
/**
|
||||||
* The processor types
|
* The processor types
|
||||||
*
|
*
|
||||||
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
|
|||||||
*/
|
*/
|
||||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||||
{
|
{
|
||||||
|
$this->last_economic_transfer_metrics = null;
|
||||||
// Require the invoice collection to be selected
|
// Require the invoice collection to be selected
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
// Require the invoice collection to be open
|
// Require the invoice collection to be open
|
||||||
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
|
|||||||
usort($orders, function ($a, $b) {
|
usort($orders, function ($a, $b) {
|
||||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||||
});
|
});
|
||||||
// Add the invoices to the invoice draft
|
// Add the invoice lines to the draft in one accumulated batch path.
|
||||||
|
$order_objects = [];
|
||||||
foreach ( $orders as $order ) {
|
foreach ( $orders as $order ) {
|
||||||
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
|
$order_object = new orders_o();
|
||||||
|
$order_object->select((int)$order['id']);
|
||||||
|
$order_object->requireSelected();
|
||||||
|
$order_objects[] = $order_object;
|
||||||
}
|
}
|
||||||
|
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
||||||
|
$this->last_economic_transfer_metrics = [
|
||||||
|
'draft_invoice_id' => $draft_id,
|
||||||
|
'currency' => (string)$currency,
|
||||||
|
...$metrics,
|
||||||
|
];
|
||||||
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
||||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||||
// Object changed
|
// Object changed
|
||||||
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLastEconomicTransferMetrics(): ?array
|
||||||
|
{
|
||||||
|
return $this->last_economic_transfer_metrics;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
||||||
* @param int $draft_id The invoice draft id
|
* @param int $draft_id The invoice draft id
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ class departments_o extends db
|
|||||||
* @return null|array
|
* @return null|array
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids, array $product_ids = [
|
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids = [], array $product_ids = [
|
||||||
25,
|
25,
|
||||||
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
||||||
22,
|
22,
|
||||||
@@ -727,6 +727,11 @@ class departments_o extends db
|
|||||||
26
|
26
|
||||||
], bool $return_as_array = false): null|array
|
], bool $return_as_array = false): null|array
|
||||||
{
|
{
|
||||||
|
$slack = new slack();
|
||||||
|
if (empty($department_ids)) {
|
||||||
|
$department_ids = $slack->get_internal_department_ids();
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($department_ids)) {
|
if (empty($department_ids)) {
|
||||||
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
||||||
}
|
}
|
||||||
@@ -847,9 +852,11 @@ class departments_o extends db
|
|||||||
$array_of_results['daily_management'][] = $tmp;
|
$array_of_results['daily_management'][] = $tmp;
|
||||||
|
|
||||||
// Send the message to the internal Slack webhook
|
// Send the message to the internal Slack webhook
|
||||||
$slack = new slack();
|
|
||||||
if (!$return_as_array) {
|
if (!$return_as_array) {
|
||||||
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
|
$webhook = $slack->get_internal_department_goal_progress_webhook_url();
|
||||||
|
if ($webhook !== '') {
|
||||||
|
$slack->send_webhook_message($tmp, $webhook);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Send a department-specific message for each internal department with the percentage of addons sold
|
// Send a department-specific message for each internal department with the percentage of addons sold
|
||||||
foreach ( $department_ids as $department_id ) {
|
foreach ( $department_ids as $department_id ) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\db;
|
||||||
|
use classes\edge_gateway_schema_bootstrap;
|
||||||
|
use classes\object_property;
|
||||||
|
use traits\db_object_t;
|
||||||
|
|
||||||
|
class edge_gateway_expected_relay_states_o extends db
|
||||||
|
{
|
||||||
|
use db_object_t;
|
||||||
|
|
||||||
|
public object_property $gateway_id;
|
||||||
|
public object_property $department_id;
|
||||||
|
public object_property $lane_id;
|
||||||
|
public object_property $role;
|
||||||
|
public object_property $relay_id;
|
||||||
|
public object_property $device_id;
|
||||||
|
public object_property $local_ip;
|
||||||
|
public object_property $device_type;
|
||||||
|
public object_property $expected_state;
|
||||||
|
public object_property $updated_at;
|
||||||
|
public object_property $completed_at;
|
||||||
|
public object_property $last_attempted_at;
|
||||||
|
public object_property $last_error;
|
||||||
|
public object_property $metadata_json;
|
||||||
|
public object_property $created_at;
|
||||||
|
public object_property $row_updated_at;
|
||||||
|
public object_property $deleted_at;
|
||||||
|
|
||||||
|
public function structure(): void
|
||||||
|
{
|
||||||
|
edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
$this->setTable('edge_gateway_expected_relay_states');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getObjectProperties(): void
|
||||||
|
{
|
||||||
|
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||||
|
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||||
|
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false);
|
||||||
|
$this->role = new object_property($this->table, $this->id, 'role', 'string', false);
|
||||||
|
$this->relay_id = new object_property($this->table, $this->id, 'relay_id', 'string', false);
|
||||||
|
$this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false);
|
||||||
|
$this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false);
|
||||||
|
$this->device_type = new object_property($this->table, $this->id, 'device_type', 'string', false);
|
||||||
|
$this->expected_state = new object_property($this->table, $this->id, 'expected_state', 'bool', false);
|
||||||
|
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
|
||||||
|
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||||
|
$this->last_attempted_at = new object_property($this->table, $this->id, 'last_attempted_at', 'string', false);
|
||||||
|
$this->last_error = new object_property($this->table, $this->id, 'last_error', 'text', false);
|
||||||
|
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||||
|
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||||
|
$this->row_updated_at = new object_property($this->table, $this->id, 'row_updated_at', 'timestamp', false);
|
||||||
|
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function objectChanged(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asArray(): array
|
||||||
|
{
|
||||||
|
$this->requireSelected();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$this->id,
|
||||||
|
'gateway_id' => (int)$this->gateway_id->value(),
|
||||||
|
'department_id' => (int)$this->department_id->value(),
|
||||||
|
'lane_id' => (int)$this->lane_id->value(),
|
||||||
|
'role' => (string)$this->role->value(),
|
||||||
|
'relay_id' => (string)$this->relay_id->value(),
|
||||||
|
'device_id' => $this->device_id->value() === null ? null : (string)$this->device_id->value(),
|
||||||
|
'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(),
|
||||||
|
'device_type' => (string)$this->device_type->value(),
|
||||||
|
'state' => (bool)$this->expected_state->value(),
|
||||||
|
'updated' => (string)$this->updated_at->value(),
|
||||||
|
'completed' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||||
|
'last_attempted_at' => $this->last_attempted_at->value() === null ? null : (string)$this->last_attempted_at->value(),
|
||||||
|
'last_error' => $this->last_error->value() === null ? null : (string)$this->last_error->value(),
|
||||||
|
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||||
|
'created_at' => (string)$this->created_at->value(),
|
||||||
|
'row_updated_at' => $this->row_updated_at->value() === null ? null : (string)$this->row_updated_at->value(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -93,6 +94,7 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Avoid SQL injection
|
// Avoid SQL injection
|
||||||
$reference = $db->escape_string($reference);
|
$reference = $db->escape_string($reference);
|
||||||
$notes = $db->escape_string($notes);
|
$notes = $db->escape_string($notes);
|
||||||
@@ -167,6 +169,7 @@ class order_items_o extends db
|
|||||||
try {
|
try {
|
||||||
// Get the order
|
// Get the order
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||||
// Get the product price
|
// Get the product price
|
||||||
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||||
|
|
||||||
@@ -354,4 +357,4 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
return (new products_o())->select((int)$this->product_id->value());
|
return (new products_o())->select((int)$this->product_id->value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
|
|||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const defaultPermissions = [
|
const defaultPermissions = [
|
||||||
'VEHICLES_LIST',
|
'VEHICLES_LIST',
|
||||||
|
'SELFSERVE_LIST',
|
||||||
'SELFSERVE_ADD',
|
'SELFSERVE_ADD',
|
||||||
'BOOKINGS_LIST',
|
'BOOKINGS_LIST',
|
||||||
'BOOKINGS_ADD',
|
'BOOKINGS_ADD',
|
||||||
'BOOKINGS_EDIT',
|
'ORDERS_LIST',
|
||||||
'BOOKINGS_DELETE',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function normalizePermissionsValue(mixed $raw): array
|
public static function normalizePermissionsValue(mixed $raw): array
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ class subusers_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error($e->getMessage());
|
$response->error($e->getMessage());
|
||||||
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +218,16 @@ class subusers_o extends db
|
|||||||
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
||||||
}
|
}
|
||||||
$cache_key = 'setup_token:' . $token;
|
$cache_key = 'setup_token:' . $token;
|
||||||
$cashe_object_id = 'subuser_setup_token';
|
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
||||||
$this->cache($cache_key, $this->id, $cashe_object_id);
|
$cache_object_id = 'subuser_setup_token';
|
||||||
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
|
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
|
||||||
|
if (is_string($existingToken) && $existingToken !== '') {
|
||||||
|
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
|
||||||
|
}
|
||||||
|
$this->cache($cache_key, $this->id, $cache_object_id);
|
||||||
|
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
|
||||||
|
$this->cache($reverse_cache_key, $token, $cache_object_id);
|
||||||
|
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +268,16 @@ class subusers_o extends db
|
|||||||
public function invalidateSetupToken(string $token): void
|
public function invalidateSetupToken(string $token): void
|
||||||
{
|
{
|
||||||
$object_id = 'subuser_setup_token';
|
$object_id = 'subuser_setup_token';
|
||||||
|
$subuser_id = $this->getSubuserIdBySetupToken($token);
|
||||||
$cache_key = 'setup_token:' . $token;
|
$cache_key = 'setup_token:' . $token;
|
||||||
$this->deleteCached($cache_key, $object_id);
|
$this->deleteCached($cache_key, $object_id);
|
||||||
|
if ($subuser_id !== null) {
|
||||||
|
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
|
||||||
|
$currentToken = $this->getCached($reverse_cache_key, $object_id);
|
||||||
|
if ((string)$currentToken === $token) {
|
||||||
|
$this->deleteCached($reverse_cache_key, $object_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7462,7 +7462,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [reg, type, wash_subscription]
|
required: [reg, type]
|
||||||
properties:
|
properties:
|
||||||
reg:
|
reg:
|
||||||
type: string
|
type: string
|
||||||
@@ -7474,6 +7474,8 @@ paths:
|
|||||||
description: Product ID representing the vehicle wash type
|
description: Product ID representing the vehicle wash type
|
||||||
wash_subscription:
|
wash_subscription:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: Defaults to false when omitted.
|
||||||
reference:
|
reference:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
@@ -11682,6 +11684,53 @@ paths:
|
|||||||
'502':
|
'502':
|
||||||
description: Slack customer registration webhook test failed
|
description: Slack customer registration webhook test failed
|
||||||
|
|
||||||
|
/slack/config/internal-department-goal-progress:
|
||||||
|
get:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Get Slack internal department goal progress config
|
||||||
|
operationId: getSlackInternalDepartmentGoalProgressConfig
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress configuration retrieved successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Update Slack internal department goal progress config
|
||||||
|
operationId: updateSlackInternalDepartmentGoalProgressConfig
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigUpdate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress configuration updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||||
|
|
||||||
|
/slack/config/internal-department-goal-progress/test:
|
||||||
|
post:
|
||||||
|
tags: [Config]
|
||||||
|
summary: Test Slack internal department goal progress webhook
|
||||||
|
operationId: testSlackInternalDepartmentGoalProgressWebhook
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slack internal department goal progress webhook test completed successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||||
|
'400':
|
||||||
|
description: Slack internal department goal progress webhook URL is not configured
|
||||||
|
'502':
|
||||||
|
description: Slack internal department goal progress webhook test failed
|
||||||
|
|
||||||
/backups/config:
|
/backups/config:
|
||||||
get:
|
get:
|
||||||
tags: [Config]
|
tags: [Config]
|
||||||
@@ -15200,11 +15249,14 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
module: { type: string, enum: [Slack] }
|
module: { type: string, enum: [Slack] }
|
||||||
variable: { type: string, enum: [customer_registration_webhook_url] }
|
variable: { type: string, enum: [customer_registration_webhook_url, internal_department_goal_progress_webhook_url, internal_department_ids] }
|
||||||
type: { type: string, enum: [string] }
|
type: { type: string, enum: [string] }
|
||||||
value:
|
value:
|
||||||
type: string
|
oneOf:
|
||||||
example: https://hooks.slack.com/services/...
|
- type: string
|
||||||
|
example: https://hooks.slack.com/services/...
|
||||||
|
- type: string
|
||||||
|
example: '[1,2,3]'
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
SlackConfigTestResult:
|
SlackConfigTestResult:
|
||||||
@@ -15215,6 +15267,42 @@ components:
|
|||||||
message: { type: string }
|
message: { type: string }
|
||||||
required: [configured, sent, message]
|
required: [configured, sent, message]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressDepartment:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
name: { type: string }
|
||||||
|
order_priority: { type: integer }
|
||||||
|
required: [id, name, order_priority]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfig:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
internal_department_goal_progress_webhook_url:
|
||||||
|
type: string
|
||||||
|
example: https://hooks.slack.com/services/...
|
||||||
|
internal_department_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: integer }
|
||||||
|
example: [1, 2, 3]
|
||||||
|
departments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressDepartment'
|
||||||
|
required: [internal_department_goal_progress_webhook_url, internal_department_ids, departments]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfigUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
internal_department_goal_progress_webhook_url:
|
||||||
|
type: string
|
||||||
|
example: https://hooks.slack.com/services/...
|
||||||
|
internal_department_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: integer }
|
||||||
|
example: [1, 2, 3]
|
||||||
|
required: [internal_department_goal_progress_webhook_url, internal_department_ids]
|
||||||
|
|
||||||
BackupsConfigEntry:
|
BackupsConfigEntry:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15499,6 +15587,14 @@ components:
|
|||||||
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||||
required: [data]
|
required: [data]
|
||||||
|
|
||||||
|
SlackInternalDepartmentGoalProgressConfigResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfig' }
|
||||||
|
required: [data]
|
||||||
|
|
||||||
BackupsConfigListResponse:
|
BackupsConfigListResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ FROM ${BASE_IMAGE}
|
|||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
|
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
||||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||||
apt-get update; \
|
|
||||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
|
||||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /opt/truckwash-edge-agent
|
WORKDIR /opt/truckwash-edge-agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||||
apt-get update; \
|
|
||||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
|
||||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /opt/truckwash-edge-agent
|
WORKDIR /opt/truckwash-edge-agent
|
||||||
|
|
||||||
|
|||||||
@@ -727,11 +727,20 @@ final class BrokerWebSocketClient
|
|||||||
throw new RuntimeException('Invalid broker websocket URL: ' . $socketUrl);
|
throw new RuntimeException('Invalid broker websocket URL: ' . $socketUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$host = (string)$parts['host'];
|
||||||
|
$path = (string)($parts['path'] ?? '/');
|
||||||
|
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
|
||||||
|
$path = substr($path, strlen('/edge-broker'));
|
||||||
|
if ($path === '') {
|
||||||
|
$path = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'scheme' => strtolower((string)($parts['scheme'] ?? 'ws')),
|
'scheme' => strtolower((string)($parts['scheme'] ?? 'ws')),
|
||||||
'host' => (string)$parts['host'],
|
'host' => $host,
|
||||||
'port' => (int)($parts['port'] ?? (((string)($parts['scheme'] ?? 'ws')) === 'wss' ? 443 : 80)),
|
'port' => (int)($parts['port'] ?? (((string)($parts['scheme'] ?? 'ws')) === 'wss' ? 443 : 80)),
|
||||||
'path' => (string)($parts['path'] ?? '/')
|
'path' => $path
|
||||||
. (isset($parts['query']) && trim((string)$parts['query']) !== '' ? '?' . $parts['query'] : ''),
|
. (isset($parts['query']) && trim((string)$parts['query']) !== '' ? '?' . $parts['query'] : ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -997,9 +1006,16 @@ final class TruckwashEdgeAgent
|
|||||||
private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
||||||
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
||||||
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
|
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
|
||||||
|
private const OUTBOX_REPLAY_BATCH_LIMIT = 3;
|
||||||
|
private const OUTBOX_REPLAY_TIMEOUT_SECONDS = 3;
|
||||||
|
private const OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS = 10;
|
||||||
|
private const OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS = 15;
|
||||||
|
private const MACHINE_SIGNAL_TIMEOUT_SECONDS = 3;
|
||||||
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
|
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
|
||||||
|
private const BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS = 1;
|
||||||
private const LOOP_STALE_AFTER_SECONDS = 30;
|
private const LOOP_STALE_AFTER_SECONDS = 30;
|
||||||
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
|
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
|
||||||
|
private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||||
|
|
||||||
private AgentConfig $config;
|
private AgentConfig $config;
|
||||||
private HttpJsonClient $http;
|
private HttpJsonClient $http;
|
||||||
@@ -1017,7 +1033,9 @@ final class TruckwashEdgeAgent
|
|||||||
private string $stagedUpdatePath;
|
private string $stagedUpdatePath;
|
||||||
private int $lastHeartbeatAt = 0;
|
private int $lastHeartbeatAt = 0;
|
||||||
private int $lastMachineSignalPollAt = 0;
|
private int $lastMachineSignalPollAt = 0;
|
||||||
|
private int $lastBrokerConnectedCommandPollAt = 0;
|
||||||
private int $lastMachineSignalMonitorRefreshAt = 0;
|
private int $lastMachineSignalMonitorRefreshAt = 0;
|
||||||
|
private int $lastOutboxFailureAt = 0;
|
||||||
private ?array $lastControlPlaneResponse = null;
|
private ?array $lastControlPlaneResponse = null;
|
||||||
private string $agentInstanceId;
|
private string $agentInstanceId;
|
||||||
|
|
||||||
@@ -1061,7 +1079,6 @@ final class TruckwashEdgeAgent
|
|||||||
$this->reloadConfigFromDisk();
|
$this->reloadConfigFromDisk();
|
||||||
$this->ensureClaimed();
|
$this->ensureClaimed();
|
||||||
$this->configureBrokerClient();
|
$this->configureBrokerClient();
|
||||||
$this->flushOutbox();
|
|
||||||
$this->pumpBrokerTransport();
|
$this->pumpBrokerTransport();
|
||||||
$this->heartbeat();
|
$this->heartbeat();
|
||||||
$this->pollMachineStartSignals();
|
$this->pollMachineStartSignals();
|
||||||
@@ -1069,12 +1086,13 @@ final class TruckwashEdgeAgent
|
|||||||
$this->pumpBrokerTransport();
|
$this->pumpBrokerTransport();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$brokerConnected = $this->isBrokerConnected();
|
||||||
$processedManagementOperation = false;
|
$processedManagementOperation = false;
|
||||||
if (!$this->isBrokerConnected()) {
|
if (!$brokerConnected) {
|
||||||
$processedManagementOperation = $this->processManagementOperation();
|
$processedManagementOperation = $this->processManagementOperation();
|
||||||
}
|
}
|
||||||
if (!$processedManagementOperation && !$this->isBrokerConnected()) {
|
if (!$processedManagementOperation && $this->shouldPollApiCommandQueue($brokerConnected)) {
|
||||||
$this->processCommandQueue();
|
$this->processCommandQueue($brokerConnected ? 0 : null);
|
||||||
}
|
}
|
||||||
$this->pumpBrokerTransport();
|
$this->pumpBrokerTransport();
|
||||||
$this->flushOutbox();
|
$this->flushOutbox();
|
||||||
@@ -1093,7 +1111,7 @@ final class TruckwashEdgeAgent
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
|
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v3');
|
||||||
try {
|
try {
|
||||||
$response = $this->http->post('/edge-agent/claim', [
|
$response = $this->http->post('/edge-agent/claim', [
|
||||||
'token' => (string)$this->config->get('installToken'),
|
'token' => (string)$this->config->get('installToken'),
|
||||||
@@ -1249,10 +1267,10 @@ final class TruckwashEdgeAgent
|
|||||||
'agent_token' => (string)$this->config->get('agentToken'),
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
'status' => 'ONLINE',
|
'status' => 'ONLINE',
|
||||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||||
'target_version' => (string)$this->config->get(
|
'target_version' => (string)$this->config->get(
|
||||||
'targetVersion',
|
'targetVersion',
|
||||||
$this->config->get('installedVersion', 'compose-php-agent-v2')
|
$this->config->get('installedVersion', 'compose-php-agent-v3')
|
||||||
),
|
),
|
||||||
'metadata' => array_merge([
|
'metadata' => array_merge([
|
||||||
'agent_instance_id' => $this->agentInstanceId,
|
'agent_instance_id' => $this->agentInstanceId,
|
||||||
@@ -1356,7 +1374,8 @@ final class TruckwashEdgeAgent
|
|||||||
$this->sendControlPlaneEvent(
|
$this->sendControlPlaneEvent(
|
||||||
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
|
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
|
||||||
$payload,
|
$payload,
|
||||||
'machine_signal'
|
'machine_signal',
|
||||||
|
self::MACHINE_SIGNAL_TIMEOUT_SECONDS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,14 +1483,29 @@ final class TruckwashEdgeAgent
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function processCommandQueue(): void
|
private function shouldPollApiCommandQueue(bool $brokerConnected): bool
|
||||||
|
{
|
||||||
|
if (!$brokerConnected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
if (($now - $this->lastBrokerConnectedCommandPollAt) < self::BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastBrokerConnectedCommandPollAt = $now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processCommandQueue(?int $waitSecondsOverride = null): void
|
||||||
{
|
{
|
||||||
$gatewayId = (int)$this->config->get('gatewayId');
|
$gatewayId = (int)$this->config->get('gatewayId');
|
||||||
if ($gatewayId <= 0) {
|
if ($gatewayId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20);
|
$waitSeconds = $waitSecondsOverride ?? (int)$this->config->get('operationPollTimeoutSeconds', 20);
|
||||||
try {
|
try {
|
||||||
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
|
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
|
||||||
'agent_token' => (string)$this->config->get('agentToken'),
|
'agent_token' => (string)$this->config->get('agentToken'),
|
||||||
@@ -1948,7 +1982,7 @@ final class TruckwashEdgeAgent
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'applied' => false,
|
'applied' => false,
|
||||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||||
'staged_version' => $targetVersion,
|
'staged_version' => $targetVersion,
|
||||||
'target_version' => $targetVersion,
|
'target_version' => $targetVersion,
|
||||||
'staged_at' => $stagedAt,
|
'staged_at' => $stagedAt,
|
||||||
@@ -2010,26 +2044,156 @@ final class TruckwashEdgeAgent
|
|||||||
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||||
$channel = (int)($request['channel'] ?? 0);
|
$channel = (int)($request['channel'] ?? 0);
|
||||||
$on = (bool)($request['on'] ?? false);
|
$on = (bool)($request['on'] ?? false);
|
||||||
|
$toggleAfter = $this->resolveRelayToggleAfterSeconds($request);
|
||||||
|
$deviceGeneration = $this->resolveShellyCommandGeneration($request);
|
||||||
|
$workerPayload = [
|
||||||
|
'local_ip' => $localIp,
|
||||||
|
'channel' => $channel,
|
||||||
|
'on' => $on,
|
||||||
|
];
|
||||||
|
if ($toggleAfter !== null) {
|
||||||
|
$workerPayload['toggle_after'] = $toggleAfter;
|
||||||
|
$workerPayload['toggleAfter'] = $toggleAfter;
|
||||||
|
}
|
||||||
|
if ($deviceGeneration !== null) {
|
||||||
|
$workerPayload['device_generation'] = $deviceGeneration;
|
||||||
|
$workerPayload['deviceGeneration'] = $deviceGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->workerHttp->post('/relay/switch', [
|
return $this->workerHttp->post('/relay/switch', $workerPayload, 8) ?? [];
|
||||||
'local_ip' => $localIp,
|
|
||||||
'channel' => $channel,
|
|
||||||
'on' => $on,
|
|
||||||
], 8) ?? [];
|
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
|
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
|
||||||
|
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
|
||||||
|
$runRpcSwitch = fn(): array => $this->http->getJson(
|
||||||
|
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
$runLegacySwitch = fn(): array => $this->http->getJson(
|
||||||
|
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($toggleAfter !== null) {
|
||||||
|
$attempts = $deviceGeneration === 1
|
||||||
|
? [$runLegacySwitch, $runRpcSwitch]
|
||||||
|
: [$runRpcSwitch, $runLegacySwitch];
|
||||||
|
$lastError = null;
|
||||||
|
foreach ($attempts as $attempt) {
|
||||||
|
try {
|
||||||
|
$attempt();
|
||||||
|
return $this->fetchShellyState($localIp, $channel);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$lastError = $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $lastError ?? new RuntimeException('Unable to switch relay');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->http->getJson($rpcUrl, 8);
|
$runRpcSwitch();
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
|
$runLegacySwitch();
|
||||||
$this->http->getJson($legacyUrl, 8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->fetchShellyState($localIp, $channel);
|
return $this->fetchShellyState($localIp, $channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveRelayToggleAfterSeconds(array $request): ?int
|
||||||
|
{
|
||||||
|
$configured = $request['toggleAfter'] ?? $request['toggle_after'] ?? $request['timer'] ?? null;
|
||||||
|
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = (int)floor((float)$configured);
|
||||||
|
if ($seconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($seconds, self::MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeShellyDeviceGeneration(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
|
||||||
|
$generation = (int)$value;
|
||||||
|
return $generation > 0 ? $generation : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inferShellyDeviceGenerationFromString(mixed $value): ?int
|
||||||
|
{
|
||||||
|
$normalized = trim((string)$value);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
|
||||||
|
return (int)$explicit[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upper = strtoupper($normalized);
|
||||||
|
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
|
||||||
|
return (int)$series[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveShellyCommandGeneration(array $request): ?int
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
$request['gen'] ?? null,
|
||||||
|
$request['generation'] ?? null,
|
||||||
|
$request['deviceGeneration'] ?? null,
|
||||||
|
$request['device_generation'] ?? null,
|
||||||
|
is_array($request['capabilities'] ?? null) ? ($request['capabilities']['generation'] ?? null) : null,
|
||||||
|
] as $candidate) {
|
||||||
|
$generation = $this->normalizeShellyDeviceGeneration($candidate);
|
||||||
|
if ($generation !== null) {
|
||||||
|
return $generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
$request['model'] ?? null,
|
||||||
|
$request['deviceModel'] ?? null,
|
||||||
|
$request['device_model'] ?? null,
|
||||||
|
$request['type'] ?? null,
|
||||||
|
$request['deviceType'] ?? null,
|
||||||
|
$request['device_type'] ?? null,
|
||||||
|
$request['app'] ?? null,
|
||||||
|
$request['name'] ?? null,
|
||||||
|
$request['deviceName'] ?? null,
|
||||||
|
$request['device_name'] ?? null,
|
||||||
|
$request['deviceId'] ?? null,
|
||||||
|
$request['device_id'] ?? null,
|
||||||
|
$request['relayId'] ?? null,
|
||||||
|
$request['relay_id'] ?? null,
|
||||||
|
$request['mac'] ?? null,
|
||||||
|
] as $candidate) {
|
||||||
|
$generation = $this->inferShellyDeviceGenerationFromString($candidate);
|
||||||
|
if ($generation !== null) {
|
||||||
|
return $generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
||||||
{
|
{
|
||||||
$directory = dirname($path);
|
$directory = dirname($path);
|
||||||
@@ -2321,12 +2485,14 @@ final class TruckwashEdgeAgent
|
|||||||
|
|
||||||
private function flushOutbox(): void
|
private function flushOutbox(): void
|
||||||
{
|
{
|
||||||
$items = $this->stateStore->queuedItems(25);
|
if ($this->shouldSkipOutboxReplay()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $this->stateStore->queuedItems(self::OUTBOX_REPLAY_BATCH_LIMIT);
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
try {
|
try {
|
||||||
$timeoutSeconds = (string)($item['type'] ?? '') === 'operation_complete'
|
$timeoutSeconds = $this->outboxReplayTimeoutSeconds((string)($item['type'] ?? ''));
|
||||||
? self::OPERATION_COMPLETE_TIMEOUT_SECONDS
|
|
||||||
: 20;
|
|
||||||
$endpoint = (string)$item['endpoint'];
|
$endpoint = (string)$item['endpoint'];
|
||||||
$payload = is_array($item['payload'] ?? null) ? (array)$item['payload'] : [];
|
$payload = is_array($item['payload'] ?? null) ? (array)$item['payload'] : [];
|
||||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||||
@@ -2334,8 +2500,10 @@ final class TruckwashEdgeAgent
|
|||||||
$this->http->post($endpoint, $payload, $timeoutSeconds);
|
$this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||||
}
|
}
|
||||||
$this->stateStore->removeOutboxItem((int)$item['id']);
|
$this->stateStore->removeOutboxItem((int)$item['id']);
|
||||||
|
$this->lastOutboxFailureAt = 0;
|
||||||
$this->recordSuccessfulSync();
|
$this->recordSuccessfulSync();
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
|
$this->lastOutboxFailureAt = time();
|
||||||
$this->recordTransportFailure(
|
$this->recordTransportFailure(
|
||||||
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
|
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
|
||||||
$throwable
|
$throwable
|
||||||
@@ -2345,6 +2513,19 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldSkipOutboxReplay(): bool
|
||||||
|
{
|
||||||
|
return $this->lastOutboxFailureAt > 0
|
||||||
|
&& (time() - $this->lastOutboxFailureAt) < self::OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function outboxReplayTimeoutSeconds(string $type): int
|
||||||
|
{
|
||||||
|
return $type === 'operation_complete'
|
||||||
|
? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS
|
||||||
|
: self::OUTBOX_REPLAY_TIMEOUT_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
private function probeWorkerHealth(): array
|
private function probeWorkerHealth(): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -2433,7 +2614,7 @@ final class TruckwashEdgeAgent
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): bool
|
||||||
{
|
{
|
||||||
$this->lastControlPlaneResponse = null;
|
$this->lastControlPlaneResponse = null;
|
||||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||||
@@ -2442,7 +2623,7 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->http->post($endpoint, $payload, 20);
|
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||||
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
|
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
|
||||||
$this->recordSuccessfulSync();
|
$this->recordSuccessfulSync();
|
||||||
return true;
|
return true;
|
||||||
@@ -2477,7 +2658,9 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
$current = trim((string)$this->config->get('brokerUrl'));
|
$current = trim((string)$this->config->get('brokerUrl'));
|
||||||
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
|
$currentNormalized = $this->normalizeBrokerUrlForComparison($current);
|
||||||
|
$nextNormalized = $this->normalizeBrokerUrlForComparison($brokerUrl);
|
||||||
|
if ($currentNormalized !== null && $currentNormalized === $nextNormalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2487,6 +2670,38 @@ final class TruckwashEdgeAgent
|
|||||||
$this->logger->info('Updated broker URL from control plane heartbeat response.');
|
$this->logger->info('Updated broker URL from control plane heartbeat response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeBrokerUrlForComparison(?string $value): ?string
|
||||||
|
{
|
||||||
|
$trimmed = rtrim(trim((string)($value ?? '')), '/');
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, 'https://')) {
|
||||||
|
$trimmed = 'wss://' . substr($trimmed, 8);
|
||||||
|
} elseif (str_starts_with($trimmed, 'http://')) {
|
||||||
|
$trimmed = 'ws://' . substr($trimmed, 7);
|
||||||
|
} elseif (!str_starts_with($trimmed, 'ws://') && !str_starts_with($trimmed, 'wss://')) {
|
||||||
|
$trimmed = 'ws://' . $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($trimmed);
|
||||||
|
if (!is_array($parts) || empty($parts['host'])) {
|
||||||
|
return strtolower($trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? 'ws'));
|
||||||
|
$host = strtolower((string)$parts['host']);
|
||||||
|
$port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'wss' ? 443 : 80);
|
||||||
|
$path = rtrim((string)($parts['path'] ?? ''), '/');
|
||||||
|
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
|
||||||
|
$path = rtrim(substr($path, strlen('/edge-broker')), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDefaultPort = ($scheme === 'wss' && $port === 443) || ($scheme !== 'wss' && $port === 80);
|
||||||
|
return $scheme . '://' . $host . ($isDefaultPort ? '' : ':' . $port) . ($path === '' ? '' : $path);
|
||||||
|
}
|
||||||
|
|
||||||
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
|
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
|
||||||
{
|
{
|
||||||
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
||||||
@@ -2796,6 +3011,13 @@ final class TruckwashEdgeAgent
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preg_match('#/edge-agent/gateways/\d+/selfserve/machine-signal$#', $endpoint)) {
|
||||||
|
return $this->sendBrokerMessage([
|
||||||
|
'type' => 'MACHINE_SIGNAL',
|
||||||
|
'payload' => $this->stripAgentAuthentication($payload),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2890,7 +3112,10 @@ final class TruckwashEdgeAgent
|
|||||||
{
|
{
|
||||||
$reloaded = AgentConfig::load($this->config->path);
|
$reloaded = AgentConfig::load($this->config->path);
|
||||||
$this->config = $reloaded;
|
$this->config = $reloaded;
|
||||||
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
|
$this->workerHttp = new HttpJsonClient(
|
||||||
|
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
|
||||||
|
$this->workerAuthorizationHeaders()
|
||||||
|
);
|
||||||
$this->configureBrokerClient();
|
$this->configureBrokerClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ services:
|
|||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD-SHELL",
|
"CMD-SHELL",
|
||||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
|
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false) { exit(1); } $$now=time(); exit(($$now - $$loopAt) <= 120 ? 0 : 1);'",
|
||||||
]
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@@ -31,14 +31,17 @@ compose_cmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print_compose_diagnostics() {
|
print_compose_diagnostics() {
|
||||||
log "docker ps --format '{{.Names}} {{.Status}}'"
|
local compose_project_name
|
||||||
docker ps --format '{{.Names}} {{.Status}}' || true
|
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
||||||
|
|
||||||
log "compose ps"
|
log "docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'"
|
||||||
compose_cmd -f "$COMPOSE_FILE" ps || true
|
docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}' || true
|
||||||
|
|
||||||
log "compose logs --tail=80"
|
log "compose ps -a --no-trunc"
|
||||||
compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true
|
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" ps -a --no-trunc || true
|
||||||
|
|
||||||
|
log "compose logs --no-color --no-log-prefix --tail=120"
|
||||||
|
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" logs --no-color --no-log-prefix --tail=120 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
config_value() {
|
config_value() {
|
||||||
@@ -61,6 +64,32 @@ config_value() {
|
|||||||
' "$CONFIG_PATH" "$key" "$fallback"
|
' "$CONFIG_PATH" "$key" "$fallback"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staged_update_value() {
|
||||||
|
local key="$1"
|
||||||
|
local fallback="${2:-}"
|
||||||
|
php -r '
|
||||||
|
$path = $argv[1];
|
||||||
|
$key = $argv[2];
|
||||||
|
$fallback = $argv[3];
|
||||||
|
if (!is_file($path)) {
|
||||||
|
echo $fallback;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
$decoded = json_decode((string)file_get_contents($path), true);
|
||||||
|
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
|
||||||
|
echo $fallback;
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
|
||||||
|
' "$STAGED_UPDATE_PATH" "$key" "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
staged_update_is_applied() {
|
||||||
|
local status
|
||||||
|
status="$(staged_update_value status '')"
|
||||||
|
[ "$status" = "APPLIED" ]
|
||||||
|
}
|
||||||
|
|
||||||
ensure_dirs() {
|
ensure_dirs() {
|
||||||
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
||||||
}
|
}
|
||||||
@@ -103,7 +132,10 @@ ensure_stack_env() {
|
|||||||
|
|
||||||
within_update_window() {
|
within_update_window() {
|
||||||
local window
|
local window
|
||||||
window="$(config_value updateWindow '02:00-04:00')"
|
window="$(staged_update_value update_window '')"
|
||||||
|
if [ -z "$window" ]; then
|
||||||
|
window="$(config_value updateWindow '02:00-04:00')"
|
||||||
|
fi
|
||||||
php -r '
|
php -r '
|
||||||
$window = $argv[1];
|
$window = $argv[1];
|
||||||
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
|
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
|
||||||
@@ -283,6 +315,10 @@ case "$ACTION" in
|
|||||||
reconcile_stack
|
reconcile_stack
|
||||||
;;
|
;;
|
||||||
reconcile)
|
reconcile)
|
||||||
|
if [ -f "$STAGED_UPDATE_PATH" ] && staged_update_is_applied; then
|
||||||
|
log "Staged update already applied; nothing to reconcile"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
|
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
|
||||||
log "Update is staged but outside the maintenance window; keeping current stack running"
|
log "Update is staged but outside the maintenance window; keeping current stack running"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||||
|
|
||||||
function worker_json_response(int $status, array $payload): void
|
function worker_json_response(int $status, array $payload): void
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
@@ -156,17 +158,173 @@ function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
|
function worker_normalize_shelly_device_generation(mixed $value): ?int
|
||||||
{
|
{
|
||||||
|
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
|
||||||
|
$generation = (int)$value;
|
||||||
|
return $generation > 0 ? $generation : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_infer_shelly_device_generation_from_string(mixed $value): ?int
|
||||||
|
{
|
||||||
|
$normalized = trim((string)$value);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
|
||||||
|
return (int)$explicit[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upper = strtoupper($normalized);
|
||||||
|
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
|
||||||
|
return (int)$series[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_resolve_shelly_command_generation(array $command): ?int
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
$command['gen'] ?? null,
|
||||||
|
$command['generation'] ?? null,
|
||||||
|
$command['deviceGeneration'] ?? null,
|
||||||
|
$command['device_generation'] ?? null,
|
||||||
|
is_array($command['capabilities'] ?? null) ? ($command['capabilities']['generation'] ?? null) : null,
|
||||||
|
] as $candidate) {
|
||||||
|
$generation = worker_normalize_shelly_device_generation($candidate);
|
||||||
|
if ($generation !== null) {
|
||||||
|
return $generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
$command['model'] ?? null,
|
||||||
|
$command['deviceModel'] ?? null,
|
||||||
|
$command['device_model'] ?? null,
|
||||||
|
$command['type'] ?? null,
|
||||||
|
$command['deviceType'] ?? null,
|
||||||
|
$command['device_type'] ?? null,
|
||||||
|
$command['app'] ?? null,
|
||||||
|
$command['name'] ?? null,
|
||||||
|
$command['deviceName'] ?? null,
|
||||||
|
$command['device_name'] ?? null,
|
||||||
|
$command['deviceId'] ?? null,
|
||||||
|
$command['device_id'] ?? null,
|
||||||
|
$command['relayId'] ?? null,
|
||||||
|
$command['relay_id'] ?? null,
|
||||||
|
$command['mac'] ?? null,
|
||||||
|
] as $candidate) {
|
||||||
|
$generation = worker_infer_shelly_device_generation_from_string($candidate);
|
||||||
|
if ($generation !== null) {
|
||||||
|
return $generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_resolve_relay_toggle_after_seconds(array $command): ?int
|
||||||
|
{
|
||||||
|
$configured = $command['toggleAfter'] ?? $command['toggle_after'] ?? $command['timer'] ?? null;
|
||||||
|
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = (int)floor((float)$configured);
|
||||||
|
if ($seconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($seconds, WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_switch_shelly_state(string $localIp, int $channel, bool $on, array $command = []): array
|
||||||
|
{
|
||||||
|
if ($localIp === '') {
|
||||||
|
throw new RuntimeException('Missing Shelly IP address');
|
||||||
|
}
|
||||||
|
|
||||||
|
$toggleAfter = worker_resolve_relay_toggle_after_seconds($command);
|
||||||
|
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
|
||||||
|
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
|
||||||
|
$runRpcSwitch = static fn(): array => worker_http_get_json(
|
||||||
|
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery)
|
||||||
|
);
|
||||||
|
$runLegacySwitch = static fn(): array => worker_http_get_json(
|
||||||
|
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($toggleAfter !== null) {
|
||||||
|
$attempts = worker_resolve_shelly_command_generation($command) === 1
|
||||||
|
? [$runLegacySwitch, $runRpcSwitch]
|
||||||
|
: [$runRpcSwitch, $runLegacySwitch];
|
||||||
|
$lastError = null;
|
||||||
|
foreach ($attempts as $attempt) {
|
||||||
|
try {
|
||||||
|
$attempt();
|
||||||
|
return worker_fetch_shelly_state($localIp, $channel);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$lastError = $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $lastError ?? new RuntimeException('Unable to switch relay');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'));
|
$runRpcSwitch();
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'));
|
$runLegacySwitch();
|
||||||
}
|
}
|
||||||
|
|
||||||
return worker_fetch_shelly_state($localIp, $channel);
|
return worker_fetch_shelly_state($localIp, $channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worker_normalize_relay_commands(array $body): array
|
||||||
|
{
|
||||||
|
$commands = $body['commands'] ?? [];
|
||||||
|
return is_array($commands) ? array_values($commands) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_relay_command_result(array $command, callable $handler): array
|
||||||
|
{
|
||||||
|
$target = trim((string)($command['target'] ?? $command['relay'] ?? ''));
|
||||||
|
$relayId = trim((string)($command['relayId'] ?? $command['relay_id'] ?? ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$localIp = trim((string)($command['local_ip'] ?? $command['localIp'] ?? ''));
|
||||||
|
$channel = (int)($command['channel'] ?? 0);
|
||||||
|
return [
|
||||||
|
'target' => $target,
|
||||||
|
'relayId' => $relayId,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'ok' => true,
|
||||||
|
'payload' => $handler($localIp, $channel, $command),
|
||||||
|
];
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
return [
|
||||||
|
'target' => $target,
|
||||||
|
'relayId' => $relayId,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $throwable->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
||||||
$body = worker_read_json_body();
|
$body = worker_read_json_body();
|
||||||
@@ -244,7 +402,50 @@ try {
|
|||||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
$channel = (int)($body['channel'] ?? 0);
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
$on = (bool)($body['on'] ?? false);
|
$on = (bool)($body['on'] ?? false);
|
||||||
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on));
|
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on, $body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $path === '/relay/batch-status') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = array_map(
|
||||||
|
fn(array $command): array => worker_relay_command_result(
|
||||||
|
$command,
|
||||||
|
fn(string $localIp, int $channel): array => worker_fetch_shelly_state($localIp, $channel)
|
||||||
|
),
|
||||||
|
worker_normalize_relay_commands($body)
|
||||||
|
);
|
||||||
|
worker_json_response(200, [
|
||||||
|
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST' && $path === '/relay/batch-switch') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = array_map(
|
||||||
|
fn(array $command): array => worker_relay_command_result(
|
||||||
|
$command,
|
||||||
|
fn(string $localIp, int $channel, array $entry): array => worker_switch_shelly_state(
|
||||||
|
$localIp,
|
||||||
|
$channel,
|
||||||
|
(bool)($entry['on'] ?? false),
|
||||||
|
$entry
|
||||||
|
)
|
||||||
|
),
|
||||||
|
worker_normalize_relay_commands($body)
|
||||||
|
);
|
||||||
|
worker_json_response(200, [
|
||||||
|
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,72 @@ class authRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
|
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
|
||||||
|
{
|
||||||
|
return 'passkey_challenge_principal:' . $challengeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
|
||||||
|
{
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPasskeyChallengePrincipal(string $challengeToken): string
|
||||||
|
{
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return 'discoverable';
|
||||||
|
}
|
||||||
|
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||||
|
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
|
||||||
|
? $principalType
|
||||||
|
: 'discoverable';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearPasskeyChallengePrincipal(string $challengeToken): void
|
||||||
|
{
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$passkeys = new passkeys_o();
|
||||||
|
$passkeys->setAdditionalWhereClause(
|
||||||
|
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
|
||||||
|
);
|
||||||
|
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||||
|
$transports = null;
|
||||||
|
if (isset($o['transports'])) {
|
||||||
|
$decoded = json_decode($o['transports'], true);
|
||||||
|
$transports = is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'type' => 'public-key',
|
||||||
|
'id' => $o['credential_id'] ?? null,
|
||||||
|
'transports' => $transports,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isset($list['items']) && is_array($list['items'])) {
|
||||||
|
$list = $list['items'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($list)
|
||||||
|
? array_values(array_filter($list, function ($item) {
|
||||||
|
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->post('/auth/login', function () {
|
$this->post('/auth/login', function () {
|
||||||
@@ -625,11 +691,53 @@ class authRoute
|
|||||||
global $response;
|
global $response;
|
||||||
$this->requireRecaptcha();
|
$this->requireRecaptcha();
|
||||||
|
|
||||||
|
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
|
||||||
|
if ($principal_type === '') {
|
||||||
|
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
|
||||||
|
}
|
||||||
|
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
|
||||||
|
$response->error('Invalid principal_type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$customer_number = self::getParameter('customer_number');
|
$customer_number = self::getParameter('customer_number');
|
||||||
$user_id = 0;
|
$user_id = 0;
|
||||||
$allowCredentials = [];
|
$allowCredentials = [];
|
||||||
|
|
||||||
if ($customer_number !== null) {
|
if ($principal_type === 'subuser') {
|
||||||
|
$subuser = null;
|
||||||
|
if (self::isParametersSet(['subuser_id'])) {
|
||||||
|
$subuser_id = (int)self::getParameter('subuser_id');
|
||||||
|
self::requireType($subuser_id, $this->type_int());
|
||||||
|
self::requireMinValue($subuser_id, 1);
|
||||||
|
$candidate = (new subusers_o())->select($subuser_id);
|
||||||
|
if ($candidate->exists()) {
|
||||||
|
$candidate->getObjectProperties();
|
||||||
|
$subuser = $candidate;
|
||||||
|
}
|
||||||
|
} elseif (self::isParametersSet(['username'])) {
|
||||||
|
$username = (string)self::getParameter('username');
|
||||||
|
self::requireType($username, $this->type_string());
|
||||||
|
self::requireMinLength('username', 3);
|
||||||
|
self::requireMaxLength('username', 255);
|
||||||
|
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
||||||
|
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
|
||||||
|
$phone_country_code = (int)self::getParameter('phone_country_code');
|
||||||
|
$phone = (int)self::getParameter('phone');
|
||||||
|
self::requireType($phone_country_code, $this->type_int());
|
||||||
|
self::requireMinLength('phone_country_code', 1);
|
||||||
|
self::requireMaxLength('phone_country_code', 3);
|
||||||
|
self::requireType($phone, $this->type_int());
|
||||||
|
self::requireMinLength('phone', 4);
|
||||||
|
self::requireMaxLength('phone', 15);
|
||||||
|
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subuser !== null) {
|
||||||
|
$user_id = (int)$subuser->id;
|
||||||
|
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||||
|
}
|
||||||
|
} elseif ($customer_number !== null) {
|
||||||
|
$principal_type = 'user';
|
||||||
$customer_number = (int)$customer_number;
|
$customer_number = (int)$customer_number;
|
||||||
self::requireType($customer_number, $this->type_int());
|
self::requireType($customer_number, $this->type_int());
|
||||||
self::requireMinValue($customer_number, 1);
|
self::requireMinValue($customer_number, 1);
|
||||||
@@ -639,33 +747,7 @@ class authRoute
|
|||||||
|
|
||||||
if ($user->exists()) {
|
if ($user->exists()) {
|
||||||
$user_id = (int)$user->id;
|
$user_id = (int)$user->id;
|
||||||
|
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
||||||
// Load passkeys for this user (non-subuser)
|
|
||||||
$passkeys = new passkeys_o();
|
|
||||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
|
|
||||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
|
||||||
$transports = null;
|
|
||||||
if (isset($o['transports'])) {
|
|
||||||
$decoded = json_decode($o['transports'], true);
|
|
||||||
$transports = is_array($decoded) ? $decoded : null;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
'type' => 'public-key',
|
|
||||||
'id' => $o['credential_id'] ?? null,
|
|
||||||
'transports' => $transports,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure we return a simple array of credentials (without pagination wrapper)
|
|
||||||
if (isset($list['items']) && is_array($list['items'])) {
|
|
||||||
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
|
|
||||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
|
||||||
}));
|
|
||||||
} elseif (is_array($list)) {
|
|
||||||
$allowCredentials = array_values(array_filter($list, function ($item) {
|
|
||||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,6 +764,10 @@ class authRoute
|
|||||||
|
|
||||||
// Create an ephemeral token to bind the challenge to the (potential) user
|
// Create an ephemeral token to bind the challenge to the (potential) user
|
||||||
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
||||||
|
$this->setPasskeyChallengePrincipal(
|
||||||
|
$challenge_token,
|
||||||
|
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
|
||||||
|
);
|
||||||
|
|
||||||
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
|
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
|
||||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
|
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
|
||||||
@@ -733,6 +819,7 @@ class authRoute
|
|||||||
if ($token_type !== 'PASSKEY_CHALLENGE') {
|
if ($token_type !== 'PASSKEY_CHALLENGE') {
|
||||||
$response->error('Invalid token type', 401);
|
$response->error('Invalid token type', 401);
|
||||||
}
|
}
|
||||||
|
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
|
||||||
|
|
||||||
// Determine rpId/host
|
// Determine rpId/host
|
||||||
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
||||||
@@ -768,7 +855,17 @@ class authRoute
|
|||||||
// Success → issue session token accordingly and delete the challenge token
|
// Success → issue session token accordingly and delete the challenge token
|
||||||
$issued_to_user_id = (int)$passkey->user_id->value();
|
$issued_to_user_id = (int)$passkey->user_id->value();
|
||||||
$is_subuser = (bool)$passkey->is_subuser->value();
|
$is_subuser = (bool)$passkey->is_subuser->value();
|
||||||
|
if (
|
||||||
|
($challengePrincipalType === 'subuser' && !$is_subuser)
|
||||||
|
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||||
|
) {
|
||||||
|
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
||||||
|
$token_o->delete($challenge_token);
|
||||||
|
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||||
|
$response->error('Invalid credential', 401);
|
||||||
|
}
|
||||||
$token_o->delete($challenge_token);
|
$token_o->delete($challenge_token);
|
||||||
|
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||||
|
|
||||||
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
|
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use classes\authentication;
|
|||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
use modules\selfserve\classes\selfserve_wash_flow;
|
use modules\selfserve\classes\selfserve_wash_flow;
|
||||||
|
use modules\subusers\helpers\subusers_permission_node_key;
|
||||||
use objects\customer_vehicles_o;
|
use objects\customer_vehicles_o;
|
||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\department_selfserve_tasks_o;
|
use objects\department_selfserve_tasks_o;
|
||||||
@@ -118,17 +119,19 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
*/
|
*/
|
||||||
$this->get('/department/selfserve/vehicle/allowed', function () {
|
$this->get('/department/selfserve/vehicle/allowed', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$user = (new authentication())->get_user();
|
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||||
if (!$user) {
|
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
|
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||||
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
|
$has_own = $this->hasPermission($own_permission);
|
||||||
if (!$has_global && !$has_own) {
|
if (!$has_global && !$has_own) {
|
||||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user === null && !$has_own) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
self::requireParameters(['lane_id', 'reg']);
|
self::requireParameters(['lane_id', 'reg']);
|
||||||
$lane_id = (int)self::getParameter('lane_id');
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||||
@@ -147,7 +150,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $actor_id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
||||||
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
|
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
|
||||||
}, [
|
}, [
|
||||||
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
|
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
|
||||||
@@ -159,17 +162,19 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
*/
|
*/
|
||||||
$this->get('/department/selfserve/washes/summary', function () {
|
$this->get('/department/selfserve/washes/summary', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$user = (new authentication())->get_user();
|
[$user] = $this->getAuthenticatedSelfServePrincipal();
|
||||||
if (!$user) {
|
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
|
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||||
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
|
$has_own = $this->hasPermission($own_permission);
|
||||||
if (!$has_global && !$has_own) {
|
if (!$has_global && !$has_own) {
|
||||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user === null && !$has_own) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$flow = $this->getWashFlow();
|
$flow = $this->getWashFlow();
|
||||||
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
||||||
try {
|
try {
|
||||||
@@ -236,18 +241,20 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
*/
|
*/
|
||||||
$this->post('/department/selfserve/vehicle/conditions', function () {
|
$this->post('/department/selfserve/vehicle/conditions', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$user = (new authentication())->get_user();
|
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||||
if (!$user) {
|
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$has_global = $user->hasPermission('add_department_selfserve_vehicle_conditions');
|
$has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions');
|
||||||
$has_own = $user->hasPermission('add_own_department_selfserve_vehicle_conditions');
|
$has_own = $this->hasPermission($own_permission);
|
||||||
|
|
||||||
if (!$has_global && !$has_own) {
|
if (!$has_global && !$has_own) {
|
||||||
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
|
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user === null && !$has_own) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$department = (int)$response->getRequestParameter('department');
|
$department = (int)$response->getRequestParameter('department');
|
||||||
$lane = (int)$response->getRequestParameter('lane');
|
$lane = (int)$response->getRequestParameter('lane');
|
||||||
$reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg'));
|
$reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg'));
|
||||||
@@ -275,7 +282,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$condition_o = new department_selfserve_vehicle_conditions_o();
|
$condition_o = new department_selfserve_vehicle_conditions_o();
|
||||||
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
|
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
|
||||||
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
|
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
|
||||||
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
|
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $actor_id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
|
||||||
$response->success([
|
$response->success([
|
||||||
'condition' => $condition_o->asArray(),
|
'condition' => $condition_o->asArray(),
|
||||||
'selfserve' => $summary,
|
'selfserve' => $summary,
|
||||||
@@ -455,6 +462,24 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
return new selfserve_wash_flow();
|
return new selfserve_wash_flow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getAuthenticatedSelfServePrincipal(): array
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$auth = new authentication();
|
||||||
|
$user = $auth->get_user();
|
||||||
|
if ($user !== false) {
|
||||||
|
return [$user, (int)$user->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subuser = $auth->get_subuser();
|
||||||
|
if ($subuser !== false) {
|
||||||
|
return [null, (int)$subuser->id];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveVehicleTypeIdFromQuery(): ?int
|
private function resolveVehicleTypeIdFromQuery(): ?int
|
||||||
{
|
{
|
||||||
$rawVehicleType = null;
|
$rawVehicleType = null;
|
||||||
@@ -549,7 +574,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function assertLaneAccess(
|
private function assertLaneAccess(
|
||||||
object $user,
|
?object $user,
|
||||||
int $laneId,
|
int $laneId,
|
||||||
bool $hasGlobalPermission = true,
|
bool $hasGlobalPermission = true,
|
||||||
bool $hasOwnPermission = false,
|
bool $hasOwnPermission = false,
|
||||||
@@ -563,7 +588,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$response->error('Department lane not found', 404);
|
$response->error('Department lane not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane)) {
|
if ($hasGlobalPermission && $user !== null && $this->userHasLaneDepartmentAccess($user, $lane)) {
|
||||||
return $lane;
|
return $lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,16 +629,17 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function summaryBelongsToCustomer(object $user, array $summary): bool
|
private function summaryBelongsToCustomer(?object $user, array $summary): bool
|
||||||
{
|
{
|
||||||
|
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||||
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
||||||
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
|
if ($session_customer_number !== null && (int)$session_customer_number === $customer_number) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reg = (string)($summary['session']['reg'] ?? '');
|
$reg = (string)($summary['session']['reg'] ?? '');
|
||||||
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
||||||
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value();
|
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === $customer_number;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function summaryDepartmentId(array $summary): int
|
private function summaryDepartmentId(array $summary): int
|
||||||
@@ -636,11 +662,15 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
return in_array($lane_department, $authorized_department_ids, true);
|
return in_array($lane_department, $authorized_department_ids, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
|
private function requireAuthenticatedCustomerNumber(?object $user, string $elevatedPermission): int
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$customer_number = (int)$user->customer_number->value();
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
|
if ($customer_number === null && $user !== null && isset($user->customer_number)) {
|
||||||
|
$customer_number = (int)$user->customer_number->value();
|
||||||
|
}
|
||||||
|
$customer_number = (int)$customer_number;
|
||||||
if ($customer_number <= 0) {
|
if ($customer_number <= 0) {
|
||||||
$response->forbidden([$elevatedPermission]);
|
$response->forbidden([$elevatedPermission]);
|
||||||
}
|
}
|
||||||
@@ -649,7 +679,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function assertSummaryAccess(
|
private function assertSummaryAccess(
|
||||||
object $user,
|
?object $user,
|
||||||
array $summary,
|
array $summary,
|
||||||
bool $hasGlobalPermission,
|
bool $hasGlobalPermission,
|
||||||
bool $hasOwnPermission,
|
bool $hasOwnPermission,
|
||||||
@@ -658,7 +688,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
if ($hasGlobalPermission && $this->userHasSummaryDepartmentAccess($user, $summary)) {
|
if ($hasGlobalPermission && $user !== null && $this->userHasSummaryDepartmentAccess($user, $summary)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ class departmentsRoute
|
|||||||
'results' => $departments_o->sendSlackInternalStatisticNotification(
|
'results' => $departments_o->sendSlackInternalStatisticNotification(
|
||||||
$week_monday,
|
$week_monday,
|
||||||
$week_sunday,
|
$week_sunday,
|
||||||
[1, 2, 3, 4, 5, 6, 7],
|
[],
|
||||||
// Default value
|
// Default value
|
||||||
[
|
[
|
||||||
25,
|
25,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace routes;
|
|||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\recaptcha;
|
use classes\recaptcha;
|
||||||
|
use classes\selfserve;
|
||||||
use classes\virkdata;
|
use classes\virkdata;
|
||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
@@ -53,31 +54,57 @@ class guestRoute
|
|||||||
$this->get('/guest/departments', function () {
|
$this->get('/guest/departments', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$departments = (new departments_o());
|
$departments = (new departments_o());
|
||||||
|
try {
|
||||||
|
$self_serve_module_enabled = (bool)(new selfserve())->config->enabled->getVariableValue();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$self_serve_module_enabled = false;
|
||||||
|
}
|
||||||
// Check if the department lane status is requested to be included
|
// Check if the department lane status is requested to be included
|
||||||
$include_lane_status = false;
|
$include_lane_status = false;
|
||||||
if (self::isParametersSet(['include_lanes']) && self::getParameter('include_lanes')) {
|
if (self::isParametersSet(['include_lanes']) && self::getParameter('include_lanes')) {
|
||||||
$include_lane_status = true;
|
$include_lane_status = true;
|
||||||
}
|
}
|
||||||
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status) {
|
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status, $self_serve_module_enabled) {
|
||||||
$additional_data = [];
|
$additional_data = [];
|
||||||
// If including lane status, fetch it
|
// If including lane status, fetch it
|
||||||
if ($include_lane_status) {
|
if ($include_lane_status) {
|
||||||
$department = $departments->select((int)$department_array['id']);
|
$department = $departments->select((int)$department_array['id']);
|
||||||
// Include the status of self-serve in the department (disabled, when staffed hours)
|
// Include the status of self-serve in the department (disabled, when staffed hours)
|
||||||
$additional_data['self_serve_enabled'] = (bool)$department->getSelfServeEnabled();
|
$department_self_serve_enabled = (bool)$department->getSelfServeEnabled();
|
||||||
|
$additional_data['self_serve_enabled'] = $department_self_serve_enabled;
|
||||||
|
$additional_data['self_serve_module_enabled'] = $self_serve_module_enabled;
|
||||||
$additional_data['lanes'] = array_map(
|
$additional_data['lanes'] = array_map(
|
||||||
/**
|
/**
|
||||||
* @param department_lanes_o $lane
|
* @param department_lanes_o $lane
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
function (department_lanes_o $lane) {
|
function (department_lanes_o $lane) use ($department_self_serve_enabled, $self_serve_module_enabled) {
|
||||||
|
$status = (string)$lane->getLaneStatus()->name;
|
||||||
|
$lane_self_serve_enabled = $lane->isSelfServeEnabled();
|
||||||
|
$self_serve_available = $self_serve_module_enabled
|
||||||
|
&& $department_self_serve_enabled
|
||||||
|
&& $lane_self_serve_enabled
|
||||||
|
&& strtoupper(trim($status)) === 'AVAILABLE';
|
||||||
|
$self_serve_unavailable_reason = null;
|
||||||
|
if (!$self_serve_module_enabled) {
|
||||||
|
$self_serve_unavailable_reason = 'self_serve_module_disabled';
|
||||||
|
} elseif (!$department_self_serve_enabled) {
|
||||||
|
$self_serve_unavailable_reason = 'department_self_serve_disabled';
|
||||||
|
} elseif (!$lane_self_serve_enabled) {
|
||||||
|
$self_serve_unavailable_reason = 'lane_self_serve_disabled';
|
||||||
|
} elseif (strtoupper(trim($status)) !== 'AVAILABLE') {
|
||||||
|
$self_serve_unavailable_reason = 'lane_status_' . strtolower($status);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int)$lane->id,
|
'id' => (int)$lane->id,
|
||||||
'name' => (string)$lane->name->value(),
|
'name' => (string)$lane->name->value(),
|
||||||
'status' => (string)$lane->getLaneStatus()->name,
|
'status' => $status,
|
||||||
'products' => $lane->getSelfServeLaneProducts(),
|
'products' => $lane->getSelfServeLaneProducts(),
|
||||||
'selfserve_enabled' => $lane->isSelfServeEnabled(),
|
'selfserve_enabled' => $lane_self_serve_enabled,
|
||||||
'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()),
|
'selfserve_available' => $self_serve_available,
|
||||||
|
'selfserve_unavailable_reason' => $self_serve_unavailable_reason,
|
||||||
|
'machine_available' => $self_serve_available && !empty($lane->relay_machine_id->value()),
|
||||||
'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null,
|
'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null,
|
||||||
];
|
];
|
||||||
}, $department->getLanes());
|
}, $department->getLanes());
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace routes;
|
||||||
|
|
||||||
|
use classes\authentication;
|
||||||
|
use classes\limited_backoffice_exception;
|
||||||
|
use classes\limited_backoffice_service;
|
||||||
|
use traits\route_t;
|
||||||
|
|
||||||
|
class limitedBackofficeRoute
|
||||||
|
{
|
||||||
|
use route_t;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->get('/limited-backoffice/departments', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
return $service->departmentsForUser($user);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$departmentId = $this->routePositiveInt('departmentId');
|
||||||
|
return $service->getDepartmentPrices($user, $departmentId);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->put('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_PRICES);
|
||||||
|
$departmentId = $this->routePositiveInt('departmentId');
|
||||||
|
return $service->updateDepartmentPrices($user, $departmentId, $this->requestPayload());
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/limited-backoffice/roles', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
|
return $service->rolePresets();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/limited-backoffice/employees', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
|
$includeInactive = strtolower((string)($this->fromQuery('include_inactive') ?? 'false')) === 'true';
|
||||||
|
return $service->employeesForUser($user, $includeInactive);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/limited-backoffice/employees', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
|
return $service->createEmployee($user, $this->requestPayload());
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->put('/limited-backoffice/employees/{employeeId}', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
|
return $service->updateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->delete('/limited-backoffice/employees/{employeeId}', function () {
|
||||||
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||||
|
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
|
return $service->deactivateEmployee($user, $this->routePositiveInt('employeeId'));
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withLimitedBackoffice(callable $callback): void
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->success($callback(new limited_backoffice_service(), $user));
|
||||||
|
} catch (limited_backoffice_exception $exception) {
|
||||||
|
$response->error($exception->payload(), $exception->statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function requestPayload(): array
|
||||||
|
{
|
||||||
|
$payload = json_decode(file_get_contents('php://input'), true);
|
||||||
|
return is_array($payload) ? $payload : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function routePositiveInt(string $name): int
|
||||||
|
{
|
||||||
|
$value = $this->fromRoute($name);
|
||||||
|
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
|
||||||
|
throw new limited_backoffice_exception('Invalid route parameter.', 400);
|
||||||
|
}
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,89 @@ class moduleConfigRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Slack internal department goal progress config > TEST */
|
||||||
|
$this->post('/slack/config/internal-department-goal-progress/test', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('slack_config');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new slack())->test_internal_department_goal_progress_webhook();
|
||||||
|
if (($result['configured'] ?? false) !== true) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress webhook URL is not configured');
|
||||||
|
$response->error($result['message'] ?? 'Slack internal department goal progress webhook URL is not configured.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($result['sent'] ?? false) !== true) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress test webhook failed');
|
||||||
|
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Successfully tested Slack internal department goal progress webhook');
|
||||||
|
$response->success($result);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'slack_config' => 'Test Slack internal department goal progress config'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Slack internal department goal progress config > GET */
|
||||||
|
$this->get('/slack/config/internal-department-goal-progress', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('slack_config');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully fetched Slack internal department goal progress config');
|
||||||
|
$response->success(
|
||||||
|
(new slack())->get_internal_department_goal_progress_config()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'slack_config' => 'Get Slack internal department goal progress config'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Slack internal department goal progress config > POST */
|
||||||
|
$this->post('/slack/config/internal-department-goal-progress', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requirePermission('slack_config');
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook_url = $response->getRequestParameter('internal_department_goal_progress_webhook_url')
|
||||||
|
?? $response->getRequestParameter('webhook_url')
|
||||||
|
?? '';
|
||||||
|
$department_ids = $response->getRequestParameter('internal_department_ids')
|
||||||
|
?? $response->getRequestParameter('department_ids')
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
if (!is_array($department_ids)) {
|
||||||
|
$department_ids = array_filter(array_map('trim', explode(',', (string)$department_ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully updated Slack internal department goal progress config');
|
||||||
|
$response->success(
|
||||||
|
(new slack())->set_internal_department_goal_progress_config(
|
||||||
|
(string)$webhook_url,
|
||||||
|
$department_ids
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'slack_config' => 'Update Slack internal department goal progress config'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->get('/backups/config', function () {
|
$this->get('/backups/config', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('backups_config');
|
$this->requirePermission('backups_config');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\router;
|
use classes\router;
|
||||||
@@ -14,11 +15,14 @@ use modules\selfserve\classes\selfserve_wash_flow;
|
|||||||
use modules\selfserve\helpers\selfserve_lane_command;
|
use modules\selfserve\helpers\selfserve_lane_command;
|
||||||
use modules\selfserve\helpers\selfserve_lane_port;
|
use modules\selfserve\helpers\selfserve_lane_port;
|
||||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||||
|
use modules\selfserve\helpers\selfserve_lane_services;
|
||||||
use modules\selfserve\helpers\selfserve_lane_state;
|
use modules\selfserve\helpers\selfserve_lane_state;
|
||||||
use modules\selfserve\helpers\selfserve_lane_status;
|
use modules\selfserve\helpers\selfserve_lane_status;
|
||||||
use modules\selfserve\helpers\selfserve_wash_session_status;
|
use modules\selfserve\helpers\selfserve_wash_session_status;
|
||||||
|
use modules\subusers\helpers\subusers_permission_node_key;
|
||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
|
use objects\edge_gateway_relay_bindings_o;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
use objects\customer_vehicles_o;
|
use objects\customer_vehicles_o;
|
||||||
@@ -715,6 +719,12 @@ class moduleSelfServeRoute
|
|||||||
// Merge services (if any)
|
// Merge services (if any)
|
||||||
$merge_services((array)$t->services->value());
|
$merge_services((array)$t->services->value());
|
||||||
}
|
}
|
||||||
|
if (!$this->isMachineWashEnabled()) {
|
||||||
|
$allowed_services = array_values(array_filter(
|
||||||
|
$allowed_services,
|
||||||
|
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
|
||||||
|
));
|
||||||
|
}
|
||||||
// Persist on lane cache (overwrites previous allowed services)
|
// Persist on lane cache (overwrites previous allowed services)
|
||||||
try {
|
try {
|
||||||
$relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services);
|
$relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services);
|
||||||
@@ -755,15 +765,23 @@ class moduleSelfServeRoute
|
|||||||
$lane = $selfserve->lane($lane_id);
|
$lane = $selfserve->lane($lane_id);
|
||||||
try {
|
try {
|
||||||
$this->applyShellyTransportOverride($lane);
|
$this->applyShellyTransportOverride($lane);
|
||||||
$lane->open($gate, $toggle_after);
|
$queued_gate_command = null;
|
||||||
|
if ($this->requestedShellyTransportOverride() === 'local') {
|
||||||
|
$queued_gate_command = $this->queueLocalLaneGateOpen($lane, $gate, $toggle_after);
|
||||||
|
} else {
|
||||||
|
$lane->open($gate, $toggle_after);
|
||||||
|
}
|
||||||
|
|
||||||
$response->success([
|
$response->success([
|
||||||
'lane_id' => $lane_id,
|
'lane_id' => $lane_id,
|
||||||
'gate' => $gate->name,
|
'gate' => $gate->name,
|
||||||
'opened' => true,
|
'opened' => true,
|
||||||
|
'queued' => $queued_gate_command !== null,
|
||||||
|
'batch' => $queued_gate_command,
|
||||||
'toggle_after' => $toggle_after,
|
'toggle_after' => $toggle_after,
|
||||||
'state' => $lane->getLaneState()->name,
|
'state' => $lane->getLaneState()->name,
|
||||||
'transport' => $this->requestedShellyTransportOverride(),
|
'transport' => $this->requestedShellyTransportOverride(),
|
||||||
]);
|
], $queued_gate_command !== null ? 202 : 200);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
|
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
|
||||||
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
|
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
|
||||||
@@ -772,6 +790,83 @@ class moduleSelfServeRoute
|
|||||||
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
|
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Modules > Self Serve > Lane > Hardware > Batch status */
|
||||||
|
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
|
||||||
|
global $response;
|
||||||
|
$selfserve = new selfserve();
|
||||||
|
self::requireParameters(['lane_id']);
|
||||||
|
$lane_id = (int)$this->getParameter('lane_id');
|
||||||
|
self::requireType($lane_id, self::type_int());
|
||||||
|
self::requireMinValue($lane_id, 1);
|
||||||
|
|
||||||
|
$lane = $selfserve->lane($lane_id);
|
||||||
|
try {
|
||||||
|
$this->applyShellyTransportOverride($lane);
|
||||||
|
$targets = self::isParametersSet(['targets']) ? (array)$this->getParameter('targets') : [
|
||||||
|
'MACHINE',
|
||||||
|
'PROGRAM_PICKER',
|
||||||
|
'CLEANER',
|
||||||
|
'ENTRANCE',
|
||||||
|
'EXIT',
|
||||||
|
];
|
||||||
|
$requests = $this->buildLaneHardwareBatchRequests($lane, $targets, false);
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
$result = (new edge_gateway_manager())->queueRelayStatusBatch(
|
||||||
|
$this->departmentIdForLane($lane),
|
||||||
|
$requests,
|
||||||
|
$user instanceof users_o ? (int)$user->id : null,
|
||||||
|
$this->requestedShellyTransportOverride() === 'local'
|
||||||
|
);
|
||||||
|
$response->success($result, 202);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$response->error('Failed to queue lane hardware status batch: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'modules_selfserve_lane_relay_machine_status_view' => 'Queue self-serve lane hardware status batch'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
|
||||||
|
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
|
||||||
|
global $response;
|
||||||
|
$selfserve = new selfserve();
|
||||||
|
self::requireParameters(['lane_id', 'commands']);
|
||||||
|
$lane_id = (int)$this->getParameter('lane_id');
|
||||||
|
self::requireType($lane_id, self::type_int());
|
||||||
|
self::requireMinValue($lane_id, 1);
|
||||||
|
|
||||||
|
$commands = (array)$this->getParameter('commands');
|
||||||
|
$lane = $selfserve->lane($lane_id);
|
||||||
|
try {
|
||||||
|
$this->applyShellyTransportOverride($lane);
|
||||||
|
$requests = $this->buildLaneHardwareBatchRequests($lane, $commands, true);
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
$result = (new edge_gateway_manager())->queueRelaySwitchBatch(
|
||||||
|
$this->departmentIdForLane($lane),
|
||||||
|
$requests,
|
||||||
|
$user instanceof users_o ? (int)$user->id : null,
|
||||||
|
$this->requestedShellyTransportOverride() === 'local'
|
||||||
|
);
|
||||||
|
$response->success($result, 202);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$response->error('Failed to queue lane hardware command batch: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'modules_selfserve_lane_relay_machine_status_set' => 'Queue self-serve lane hardware command batch'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Modules > Self Serve > Lane > Hardware > Batch poll */
|
||||||
|
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
|
||||||
|
global $response;
|
||||||
|
self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
|
||||||
|
try {
|
||||||
|
$response->success((new edge_gateway_manager())->relayBatchStatus((string)$this->fromRoute('batch_id')));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$response->error('Failed to get lane hardware batch status: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'modules_selfserve_lane_relay_machine_status_view' => 'Poll self-serve lane hardware batch'
|
||||||
|
]);
|
||||||
|
|
||||||
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
|
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
|
||||||
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
|
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
|
||||||
global $response;
|
global $response;
|
||||||
@@ -1330,8 +1425,7 @@ class moduleSelfServeRoute
|
|||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
if (!$this->hasAuthenticatedUserOrSubuser()) {
|
||||||
if (!$user) {
|
|
||||||
$response->error('Authentication failed. Invalid or missing token.', 401);
|
$response->error('Authentication failed. Invalid or missing token.', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,7 +1438,7 @@ class moduleSelfServeRoute
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) {
|
if (self::hasPermission($this->customerSelfServePermission())) {
|
||||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
if ($customer_number !== null && $customer_number > 0) {
|
if ($customer_number !== null && $customer_number > 0) {
|
||||||
return (int)$customer_number;
|
return (int)$customer_number;
|
||||||
@@ -1353,7 +1447,7 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
$this->emitForbidden([
|
$this->emitForbidden([
|
||||||
'modules_selfserve_lane_wash_in_progress_view',
|
'modules_selfserve_lane_wash_in_progress_view',
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
$this->customerSelfServePermission(),
|
||||||
]);
|
]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1412,13 +1506,12 @@ class moduleSelfServeRoute
|
|||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$user = (new authentication())->get_user();
|
if (!$this->hasAuthenticatedUserOrSubuser()) {
|
||||||
if (!$user) {
|
|
||||||
$response->error('Authentication failed. Invalid or missing token.', 401);
|
$response->error('Authentication failed. Invalid or missing token.', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
if (!self::hasPermission($this->customerSelfServePermission())) {
|
||||||
$this->emitForbidden([self::CUSTOMER_SELFSERVE_PERMISSION]);
|
$this->emitForbidden([$this->customerSelfServePermission()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
@@ -1429,6 +1522,17 @@ class moduleSelfServeRoute
|
|||||||
return (int)$customer_number;
|
return (int)$customer_number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function customerSelfServePermission(): \classes\permission_node
|
||||||
|
{
|
||||||
|
return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasAuthenticatedUserOrSubuser(): bool
|
||||||
|
{
|
||||||
|
$auth = new authentication();
|
||||||
|
return $auth->get_user() !== false || $auth->get_subuser() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
|
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
|
||||||
{
|
{
|
||||||
if ($customer_number <= 0) {
|
if ($customer_number <= 0) {
|
||||||
@@ -1624,6 +1728,305 @@ class moduleSelfServeRoute
|
|||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
private function queueLocalLaneGateOpen(selfserve_lane $lane, selfserve_lane_port $gate, int $toggleAfter): array
|
||||||
|
{
|
||||||
|
$requests = $this->buildLaneHardwareBatchRequests($lane, [[
|
||||||
|
'target' => $gate->name,
|
||||||
|
'action' => 'OPEN',
|
||||||
|
'toggle_after' => $toggleAfter,
|
||||||
|
]], true);
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
|
||||||
|
return (new edge_gateway_manager())->queueRelaySwitchBatch(
|
||||||
|
$this->departmentIdForLane($lane),
|
||||||
|
$requests,
|
||||||
|
$user instanceof users_o ? (int)$user->id : null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,mixed> $items
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
private function buildLaneHardwareBatchRequests(selfserve_lane $lane, array $items, bool $mutation): array
|
||||||
|
{
|
||||||
|
if (empty($lane->department_lane)) {
|
||||||
|
throw new \Exception("Department lane object not found for lane ID {$lane->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$command = is_array($item) ? $item : ['target' => $item];
|
||||||
|
$target = strtoupper(trim((string)($command['target'] ?? $command['relay'] ?? '')));
|
||||||
|
if ($target === '') {
|
||||||
|
throw new \Exception('Lane hardware batch commands require a target');
|
||||||
|
}
|
||||||
|
if (isset($seen[$target])) {
|
||||||
|
throw new \Exception('Duplicate lane hardware target: ' . $target);
|
||||||
|
}
|
||||||
|
$seen[$target] = true;
|
||||||
|
|
||||||
|
$relayId = $this->relayIdForLaneHardwareTarget($lane, $target);
|
||||||
|
if ($relayId === '') {
|
||||||
|
throw new \Exception('No relay is configured for lane hardware target ' . $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mutation) {
|
||||||
|
$this->requireLaneHardwareMutationPermission($target);
|
||||||
|
} else {
|
||||||
|
$this->requireLaneHardwareStatusPermission($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = [
|
||||||
|
'target' => $target,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'relayId' => $relayId,
|
||||||
|
'lane_id' => (int)$lane->id,
|
||||||
|
'laneId' => (int)$lane->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($mutation) {
|
||||||
|
if (in_array($target, ['ENTRANCE', 'EXIT'], true)) {
|
||||||
|
$action = strtoupper(trim((string)($command['action'] ?? 'OPEN')));
|
||||||
|
if ($action !== 'OPEN') {
|
||||||
|
throw new \Exception($target . ' only supports action=OPEN');
|
||||||
|
}
|
||||||
|
$request['on'] = true;
|
||||||
|
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
|
||||||
|
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'] ?? 1,
|
||||||
|
self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!array_key_exists('on', $command)) {
|
||||||
|
throw new \Exception($target . ' relay commands require an on boolean');
|
||||||
|
}
|
||||||
|
$on = is_bool($command['on'])
|
||||||
|
? $command['on']
|
||||||
|
: filter_var($command['on'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||||
|
if ($on === null) {
|
||||||
|
throw new \Exception('Invalid on value for ' . $target . '. Expected boolean true/false.');
|
||||||
|
}
|
||||||
|
$request['on'] = (bool)$on;
|
||||||
|
if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) {
|
||||||
|
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
|
||||||
|
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'],
|
||||||
|
86400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$requests[] = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requests === []) {
|
||||||
|
throw new \Exception('At least one lane hardware target is required');
|
||||||
|
}
|
||||||
|
if (count($requests) > 5) {
|
||||||
|
throw new \Exception('Lane hardware batches are limited to five targets');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function relayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
|
||||||
|
{
|
||||||
|
$relayId = trim((string)match ($target) {
|
||||||
|
'MACHINE' => $lane->department_lane->relay_machine_id->value(),
|
||||||
|
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => $lane->department_lane->relay_machine_program_picker_id->value(),
|
||||||
|
'CLEANER', 'MACHINE_CLEANER' => $lane->department_lane->relay_machine_cleaner_id->value(),
|
||||||
|
'ENTRANCE' => $lane->department_lane->relay_in_id->value(),
|
||||||
|
'EXIT' => $lane->department_lane->relay_out_id->value(),
|
||||||
|
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($relayId !== '') {
|
||||||
|
return $relayId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function edgeGatewayRelayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
|
||||||
|
{
|
||||||
|
$departmentId = $this->departmentIdForLane($lane);
|
||||||
|
if ($departmentId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'deleted_at' => null,
|
||||||
|
], ['id']);
|
||||||
|
|
||||||
|
$laneScoped = [];
|
||||||
|
$unscoped = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
|
||||||
|
if (!$binding->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $binding->asArray();
|
||||||
|
if (!$this->edgeGatewayBindingMatchesHardwareTarget($payload, $target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->edgeGatewayBindingMatchesLane($payload, (int)$lane->id, $target)) {
|
||||||
|
$laneScoped[] = (string)$payload['relay_id'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->edgeGatewayBindingHasConsumers($payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unscoped[] = (string)$payload['relay_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $laneScoped !== [] ? array_values(array_unique($laneScoped)) : array_values(array_unique($unscoped));
|
||||||
|
if (count($candidates) === 1) {
|
||||||
|
return $candidates[0];
|
||||||
|
}
|
||||||
|
if (count($candidates) > 1) {
|
||||||
|
throw new \Exception('Multiple edge gateway relay bindings match lane hardware target ' . $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $binding
|
||||||
|
*/
|
||||||
|
private function edgeGatewayBindingMatchesHardwareTarget(array $binding, string $target): bool
|
||||||
|
{
|
||||||
|
$accepted = match ($target) {
|
||||||
|
'MACHINE' => ['MACHINE'],
|
||||||
|
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
|
||||||
|
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
|
||||||
|
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
|
||||||
|
'EXIT' => ['EXIT'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
if ($accepted === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||||
|
$values = [
|
||||||
|
$binding['role'] ?? null,
|
||||||
|
$binding['service'] ?? null,
|
||||||
|
$binding['slot'] ?? null,
|
||||||
|
$metadata['role'] ?? null,
|
||||||
|
$metadata['relay_role'] ?? null,
|
||||||
|
$metadata['service'] ?? null,
|
||||||
|
$metadata['slot'] ?? null,
|
||||||
|
];
|
||||||
|
foreach ((array)($binding['services'] ?? $metadata['services'] ?? []) as $service) {
|
||||||
|
$values[] = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (in_array($this->normalizeLaneHardwareTargetRole($value), $accepted, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $binding
|
||||||
|
*/
|
||||||
|
private function edgeGatewayBindingMatchesLane(array $binding, int $laneId, string $target): bool
|
||||||
|
{
|
||||||
|
$targetRoles = match ($target) {
|
||||||
|
'MACHINE' => ['MACHINE'],
|
||||||
|
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
|
||||||
|
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
|
||||||
|
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
|
||||||
|
'EXIT' => ['EXIT'],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||||
|
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
|
||||||
|
if (!is_array($consumers)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($consumers as $consumer) {
|
||||||
|
if (!is_array($consumer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strtolower(trim((string)($consumer['type'] ?? ''))) !== 'lane') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int)($consumer['id'] ?? 0) !== $laneId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slot = $this->normalizeLaneHardwareTargetRole($consumer['slot'] ?? '');
|
||||||
|
if ($slot === '' || in_array($slot, $targetRoles, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $binding
|
||||||
|
*/
|
||||||
|
private function edgeGatewayBindingHasConsumers(array $binding): bool
|
||||||
|
{
|
||||||
|
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||||
|
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
|
||||||
|
return is_array($consumers) && $consumers !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeLaneHardwareTargetRole(mixed $value): string
|
||||||
|
{
|
||||||
|
return strtoupper(str_replace(['-', ' '], '_', trim((string)($value ?? ''))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireLaneHardwareStatusPermission(string $target): void
|
||||||
|
{
|
||||||
|
match ($target) {
|
||||||
|
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_view'),
|
||||||
|
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'),
|
||||||
|
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'),
|
||||||
|
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
|
||||||
|
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireLaneHardwareMutationPermission(string $target): void
|
||||||
|
{
|
||||||
|
match ($target) {
|
||||||
|
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_set'),
|
||||||
|
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'),
|
||||||
|
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'),
|
||||||
|
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
|
||||||
|
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBatchToggleAfter(mixed $value, int $max): int
|
||||||
|
{
|
||||||
|
$seconds = (int)$value;
|
||||||
|
if ($seconds <= 0) {
|
||||||
|
throw new \Exception('toggle_after must be greater than zero');
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($seconds, $max);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
@@ -1685,7 +2088,7 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
|
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
|
||||||
if ($allow_customer_self_serve) {
|
if ($allow_customer_self_serve) {
|
||||||
$missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION;
|
$missing_permissions[] = $this->customerSelfServePermission();
|
||||||
}
|
}
|
||||||
$this->emitForbidden($missing_permissions);
|
$this->emitForbidden($missing_permissions);
|
||||||
}
|
}
|
||||||
@@ -1729,7 +2132,7 @@ class moduleSelfServeRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireSelfServeLaneCommandPermission(
|
private function requireSelfServeLaneCommandPermission(
|
||||||
@@ -1760,7 +2163,7 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
$this->emitForbidden(
|
$this->emitForbidden(
|
||||||
$allow_customer_self_serve
|
$allow_customer_self_serve
|
||||||
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
|
? [...$elevated_permissions, $this->customerSelfServePermission()]
|
||||||
: $elevated_permissions
|
: $elevated_permissions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1791,28 +2194,37 @@ class moduleSelfServeRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function isSelfServeModuleEnabled(): bool
|
protected function isSelfServeModuleEnabled(): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return (bool)(new selfserve())->config->enabled->getVariableValue();
|
return (new selfserve())->config->enabled->isTrue();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function isMachineWashEnabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (new selfserve())->config->machine_wash_enabled->isTrue();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
return $customer_number > 0
|
return $customer_number > 0
|
||||||
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
|
&& $this->hasPermission($this->customerSelfServePermission())
|
||||||
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1859,7 +2271,7 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,48 @@ class passkeysRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{principal:object,user_id:int,is_subuser:bool}
|
||||||
|
*/
|
||||||
|
private function resolvePasskeyPrincipal(string $classicUserPermission): array
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$auth = new authentication();
|
||||||
|
$subuser = $auth->get_subuser();
|
||||||
|
if ($subuser !== false) {
|
||||||
|
return [
|
||||||
|
'principal' => $subuser,
|
||||||
|
'user_id' => (int)$subuser->id,
|
||||||
|
'is_subuser' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requirePermission($classicUserPermission);
|
||||||
|
$user = $auth->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_AUTH', 'User not logged in');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'principal' => $user,
|
||||||
|
'user_id' => (int)$user->id,
|
||||||
|
'is_subuser' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// List passkeys for current authenticated user
|
// List passkeys for current authenticated user
|
||||||
$this->get('/account/security/passkeys', function () {
|
$this->get('/account/security/passkeys', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('user_security_passkeys_list');
|
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$passkeys = new passkeys_o();
|
$passkeys = new passkeys_o();
|
||||||
// Restrict to current user (customer) and non-subuser records
|
$passkeys->setAdditionalWhereClause(
|
||||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user->id . ' AND `is_subuser` = 0');
|
'`user_id` = ' . (int)$principal['user_id'] . ' AND `is_subuser` = ' . ($principal['is_subuser'] ? '1' : '0')
|
||||||
|
);
|
||||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||||
// $o is an associative array from the database
|
// $o is an associative array from the database
|
||||||
$transports = null;
|
$transports = null;
|
||||||
@@ -45,7 +72,7 @@ class passkeysRoute
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
|
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
|
||||||
$response->success($list);
|
$response->success($list);
|
||||||
}, [
|
}, [
|
||||||
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
|
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
|
||||||
@@ -54,12 +81,7 @@ class passkeysRoute
|
|||||||
// Create/add a passkey (store after client-side WebAuthn attestation)
|
// Create/add a passkey (store after client-side WebAuthn attestation)
|
||||||
$this->post('/account/security/passkeys', function () {
|
$this->post('/account/security/passkeys', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('user_security_passkeys_create');
|
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
|
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
|
||||||
|
|
||||||
@@ -100,9 +122,9 @@ class passkeysRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$obj = new passkeys_o();
|
$obj = new passkeys_o();
|
||||||
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
|
$obj->add((int)$principal['user_id'], (bool)$principal['is_subuser'], $credential_id, $public_key, $algorithm, (array)$transports, $name);
|
||||||
|
|
||||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
|
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
|
||||||
$response->success(['id' => $obj->id]);
|
$response->success(['id' => $obj->id]);
|
||||||
}, [
|
}, [
|
||||||
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
|
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
|
||||||
@@ -111,12 +133,7 @@ class passkeysRoute
|
|||||||
// Rename a passkey
|
// Rename a passkey
|
||||||
$this->patch('/account/security/passkeys/{id}', function () {
|
$this->patch('/account/security/passkeys/{id}', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('user_security_passkeys_rename');
|
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = (int)self::fromRoute('id');
|
$id = (int)self::fromRoute('id');
|
||||||
self::requireParameterIntPositive($id, 'id');
|
self::requireParameterIntPositive($id, 'id');
|
||||||
@@ -127,13 +144,17 @@ class passkeysRoute
|
|||||||
self::requireMaxLength('name', 255);
|
self::requireMaxLength('name', 255);
|
||||||
|
|
||||||
$obj = (new passkeys_o())->select($id);
|
$obj = (new passkeys_o())->select($id);
|
||||||
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
if (
|
||||||
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
|
!$obj->exists()
|
||||||
|
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|
||||||
|
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
|
||||||
|
) {
|
||||||
|
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
|
||||||
$response->error('Not found', 404);
|
$response->error('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$obj->update(['name' => $name]);
|
$obj->update(['name' => $name]);
|
||||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
|
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
|
||||||
$response->success(['message' => 'Renamed', 'id' => $id]);
|
$response->success(['message' => 'Renamed', 'id' => $id]);
|
||||||
}, [
|
}, [
|
||||||
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
|
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
|
||||||
@@ -142,24 +163,23 @@ class passkeysRoute
|
|||||||
// Delete a passkey (soft delete)
|
// Delete a passkey (soft delete)
|
||||||
$this->delete('/account/security/passkeys/{id}', function () {
|
$this->delete('/account/security/passkeys/{id}', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('user_security_passkeys_delete');
|
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = (int)self::fromRoute('id');
|
$id = (int)self::fromRoute('id');
|
||||||
self::requireParameterIntPositive($id, 'id');
|
self::requireParameterIntPositive($id, 'id');
|
||||||
|
|
||||||
$obj = (new passkeys_o())->select($id);
|
$obj = (new passkeys_o())->select($id);
|
||||||
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
if (
|
||||||
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
|
!$obj->exists()
|
||||||
|
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|
||||||
|
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
|
||||||
|
) {
|
||||||
|
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
|
||||||
$response->error('Not found', 404);
|
$response->error('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$obj->delete();
|
$obj->delete();
|
||||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
|
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
|
||||||
$response->success(['message' => 'Deleted', 'id' => $id]);
|
$response->success(['message' => 'Deleted', 'id' => $id]);
|
||||||
}, [
|
}, [
|
||||||
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
|
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
|
||||||
|
|||||||
@@ -231,7 +231,57 @@ class subusersRoute
|
|||||||
|
|
||||||
private function buildSetupLink(string $token): string
|
private function buildSetupLink(string $token): string
|
||||||
{
|
{
|
||||||
return 'https://truckwash.io/complete-registration?token=' . $token;
|
$frontendBaseUrl = trim((string)(
|
||||||
|
getenv('FRONTEND_URL')
|
||||||
|
?: getenv('APP_URL')
|
||||||
|
?: ($_SERVER['FRONTEND_URL'] ?? '')
|
||||||
|
?: ($_SERVER['APP_URL'] ?? '')
|
||||||
|
?: 'https://truckwash.io'
|
||||||
|
));
|
||||||
|
$frontendBaseUrl = rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/');
|
||||||
|
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clientThrottleIp(): string
|
||||||
|
{
|
||||||
|
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
|
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
|
||||||
|
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
|
||||||
|
'sha256',
|
||||||
|
$this->clientThrottleIp() . ':' . $identifier
|
||||||
|
);
|
||||||
|
$redis = constant('redis');
|
||||||
|
$attempts = (int)($redis->get($key) ?? '0');
|
||||||
|
if ($attempts >= $limit) {
|
||||||
|
$response->error('Too many attempts. Please wait and try again.', 429);
|
||||||
|
}
|
||||||
|
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearThrottleAttempt(?string $key): void
|
||||||
|
{
|
||||||
|
if ($key === null || !defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
constant('redis')->delete($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subuserAuthFailure(): void
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
$response->error('Invalid credentials', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function issueSetupInvite(subusers_o $subuser): array
|
private function issueSetupInvite(subusers_o $subuser): array
|
||||||
@@ -272,7 +322,7 @@ class subusersRoute
|
|||||||
$delivery = [
|
$delivery = [
|
||||||
'channel' => 'sms',
|
'channel' => 'sms',
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'message' => $exception->getMessage(),
|
'message' => 'Invite delivery failed.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +380,7 @@ class subusersRoute
|
|||||||
'subuser' => $subuser->id,
|
'subuser' => $subuser->id,
|
||||||
'enabled' => 1,
|
'enabled' => 1,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
], ['permissions', 'billing_customer_number']);
|
], ['id', 'permissions', 'billing_customer_number']);
|
||||||
$customerNames = $this->resolveCustomerNames(array_map(
|
$customerNames = $this->resolveCustomerNames(array_map(
|
||||||
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
|
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
|
||||||
$grants
|
$grants
|
||||||
@@ -346,6 +396,7 @@ class subusersRoute
|
|||||||
'grants' => array_map(function ($grant) use ($customerNames) {
|
'grants' => array_map(function ($grant) use ($customerNames) {
|
||||||
$customerNumber = (int)$grant['billing_customer_number'];
|
$customerNumber = (int)$grant['billing_customer_number'];
|
||||||
return [
|
return [
|
||||||
|
'grant_id' => isset($grant['id']) ? (int)$grant['id'] : null,
|
||||||
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
|
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
|
||||||
'billing_customer_number' => $customerNumber,
|
'billing_customer_number' => $customerNumber,
|
||||||
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
|
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
|
||||||
@@ -762,28 +813,9 @@ class subusersRoute
|
|||||||
self::requireType($note, self::type_string());
|
self::requireType($note, self::type_string());
|
||||||
self::requireMaxLength('note', 65535);
|
self::requireMaxLength('note', 65535);
|
||||||
}
|
}
|
||||||
$permissions = null;
|
$permissions = self::isParametersSet(['permissions'])
|
||||||
if (self::isParametersSet(['permissions'])) {
|
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||||
$raw = self::getParameter('permissions');
|
: null;
|
||||||
// Expect array (already parsed) or JSON string
|
|
||||||
if (is_string($raw)) {
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
if (!is_array($decoded)) {
|
|
||||||
$response->error('Invalid permissions payload', 400);
|
|
||||||
}
|
|
||||||
$permissions = $decoded;
|
|
||||||
} elseif (is_array($raw)) {
|
|
||||||
$permissions = $raw;
|
|
||||||
} else {
|
|
||||||
$response->error('Invalid permissions type', 400);
|
|
||||||
}
|
|
||||||
// Validate each permission is a known key
|
|
||||||
foreach ($permissions as $perm) {
|
|
||||||
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
|
||||||
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
||||||
$response->success(['grant' => $grant->asArray()]);
|
$response->success(['grant' => $grant->asArray()]);
|
||||||
@@ -834,25 +866,7 @@ class subusersRoute
|
|||||||
$grant->note->set($note);
|
$grant->note->set($note);
|
||||||
}
|
}
|
||||||
if (self::isParametersSet(['permissions'])) {
|
if (self::isParametersSet(['permissions'])) {
|
||||||
$raw = self::getParameter('permissions');
|
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
|
||||||
// Expect array (already parsed) or JSON string
|
|
||||||
if (is_string($raw)) {
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
if (!is_array($decoded)) {
|
|
||||||
$response->error('Invalid permissions payload', 400);
|
|
||||||
}
|
|
||||||
$permissions = $decoded;
|
|
||||||
} elseif (is_array($raw)) {
|
|
||||||
$permissions = $raw;
|
|
||||||
} else {
|
|
||||||
$response->error('Invalid permissions type', 400);
|
|
||||||
}
|
|
||||||
// Validate each permission is a known key
|
|
||||||
foreach ($permissions as $perm) {
|
|
||||||
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
|
||||||
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$grant->permissions->set($permissions);
|
$grant->permissions->set($permissions);
|
||||||
}
|
}
|
||||||
$response->success($grant->asArray());
|
$response->success($grant->asArray());
|
||||||
@@ -866,6 +880,14 @@ class subusersRoute
|
|||||||
|
|
||||||
$this->get('/subusers/permission-nodes', function () {
|
$this->get('/subusers/permission-nodes', function () {
|
||||||
global $response;
|
global $response;
|
||||||
|
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|
||||||
|
|| self::hasPermission('list_subusers')
|
||||||
|
|| self::hasPermission('add_subusers')
|
||||||
|
|| self::hasPermission('edit_subusers');
|
||||||
|
if (!$canUseGlobalManagement) {
|
||||||
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
// Build groups
|
// Build groups
|
||||||
$groups = [
|
$groups = [
|
||||||
new subusers_permission_nodes_bookings(),
|
new subusers_permission_nodes_bookings(),
|
||||||
@@ -893,7 +915,13 @@ class subusersRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
$response->success($out);
|
$response->success($out);
|
||||||
}, []);
|
}, [
|
||||||
|
'list_own_subusers' => 'List chauffeur permission nodes for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
|
||||||
|
'manage_subuser_grants' => 'List chauffeur permission nodes for administrative grant management.',
|
||||||
|
'list_subusers' => 'List chauffeur permission nodes for superuser management.',
|
||||||
|
'add_subusers' => 'List chauffeur permission nodes while inviting chauffeurs.',
|
||||||
|
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->post('/subusers', function () {
|
$this->post('/subusers', function () {
|
||||||
global /** @var response $response */
|
global /** @var response $response */
|
||||||
@@ -948,15 +976,7 @@ class subusersRoute
|
|||||||
(int)$phone_country_code,
|
(int)$phone_country_code,
|
||||||
(int)$phone
|
(int)$phone
|
||||||
);
|
);
|
||||||
// Send an SMS with a link to complete the registration process.
|
$invite = $this->issueSetupInvite($subuser);
|
||||||
$gatewayAPI = new gatewayapi();
|
|
||||||
if ($gatewayAPI->isEnabled()) {
|
|
||||||
$token = $subuser->generateSetupToken();
|
|
||||||
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
|
||||||
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
|
||||||
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
|
||||||
$gatewayAPI->send($phone_number_array, $message);
|
|
||||||
}
|
|
||||||
// Add the grant request
|
// Add the grant request
|
||||||
$subuser_grants_o = new subuser_grants_o();
|
$subuser_grants_o = new subuser_grants_o();
|
||||||
try {
|
try {
|
||||||
@@ -964,7 +984,7 @@ class subusersRoute
|
|||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error('Failed to add subuser grant', 500);
|
$response->error('Failed to add subuser grant', 500);
|
||||||
}
|
}
|
||||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
||||||
// Code for creating a new subuser would go here
|
// Code for creating a new subuser would go here
|
||||||
});
|
});
|
||||||
$this->get('/subusers/setup', function () {
|
$this->get('/subusers/setup', function () {
|
||||||
@@ -972,11 +992,13 @@ class subusersRoute
|
|||||||
global $response;
|
global $response;
|
||||||
self::requireParameters(['token']);
|
self::requireParameters(['token']);
|
||||||
$token = self::getParameter('token');
|
$token = self::getParameter('token');
|
||||||
|
$setupThrottleKey = $this->recordThrottleAttempt('setup_token', 'token-validation', 20, 15 * 60);
|
||||||
// Get the subuser with the setup token
|
// Get the subuser with the setup token
|
||||||
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
|
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
|
||||||
if ($subuser === null) {
|
if ($subuser === null) {
|
||||||
$response->error('Invalid or expired token', 400);
|
$response->error('Invalid or expired token', 400);
|
||||||
}
|
}
|
||||||
|
$this->clearThrottleAttempt($setupThrottleKey);
|
||||||
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
|
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
|
||||||
});
|
});
|
||||||
$this->post('/subusers/setup', function () {
|
$this->post('/subusers/setup', function () {
|
||||||
@@ -986,6 +1008,7 @@ class subusersRoute
|
|||||||
$token = (string)self::getParameter('token');
|
$token = (string)self::getParameter('token');
|
||||||
$password = (string)self::getParameter('password');
|
$password = (string)self::getParameter('password');
|
||||||
$name = (string)self::getParameter('name');
|
$name = (string)self::getParameter('name');
|
||||||
|
$setupThrottleKey = $this->recordThrottleAttempt('setup_complete', 'token-complete', 20, 15 * 60);
|
||||||
$this->requireSubuserPasswordPolicy($password);
|
$this->requireSubuserPasswordPolicy($password);
|
||||||
self::requireType($name, self::type_string());
|
self::requireType($name, self::type_string());
|
||||||
self::requireMinLength('name', 3);
|
self::requireMinLength('name', 3);
|
||||||
@@ -1017,18 +1040,24 @@ class subusersRoute
|
|||||||
if ($subuser === null) {
|
if ($subuser === null) {
|
||||||
$response->error('Invalid or expired token', 400);
|
$response->error('Invalid or expired token', 400);
|
||||||
}
|
}
|
||||||
|
$this->assertSubuserIdentifiersAvailable(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$username,
|
||||||
|
$email,
|
||||||
|
(int)$subuser->id
|
||||||
|
);
|
||||||
// Set the password for the subuser
|
// Set the password for the subuser
|
||||||
try {
|
try {
|
||||||
$subuser->setPassword($password);
|
$subuser->update([
|
||||||
if (!empty($username) || !empty($email) || !empty($name)) {
|
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
$subuser->update([
|
'name' => $name,
|
||||||
...(!empty($username) ? ['username' => $username] : []),
|
...(!empty($username) ? ['username' => $username] : []),
|
||||||
...(!empty($email) ? ['email' => $email] : []),
|
...(!empty($email) ? ['email' => $email] : []),
|
||||||
...(!empty($name) ? ['name' => $name] : []),
|
]);
|
||||||
]);
|
|
||||||
}
|
|
||||||
// Invalidate the setup token
|
// Invalidate the setup token
|
||||||
$subuser->invalidateSetupToken($token);
|
$subuser->invalidateSetupToken($token);
|
||||||
|
$this->clearThrottleAttempt($setupThrottleKey);
|
||||||
$response->success(['message' => 'Complete registration successful']);
|
$response->success(['message' => 'Complete registration successful']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error($e->getMessage(), 400);
|
$response->error($e->getMessage(), 400);
|
||||||
@@ -1067,23 +1096,34 @@ class subusersRoute
|
|||||||
} else {
|
} else {
|
||||||
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
|
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
|
||||||
}
|
}
|
||||||
|
$identifier = $username !== null
|
||||||
|
? 'username:' . strtolower($username)
|
||||||
|
: ($subuser_id !== null
|
||||||
|
? 'id:' . (string)$subuser_id
|
||||||
|
: 'phone:' . (string)$phone_country_code . ':' . (string)$phone);
|
||||||
|
$authThrottleKey = $this->recordThrottleAttempt('auth_password', $identifier, 10, 15 * 60);
|
||||||
// Get the subuser based on the provided username type
|
// Get the subuser based on the provided username type
|
||||||
$subuser = null;
|
$subuser = null;
|
||||||
if ($phone_country_code !== null && $phone !== null) {
|
if ($phone_country_code !== null && $phone !== null) {
|
||||||
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
||||||
} elseif ($subuser_id !== null) {
|
} elseif ($subuser_id !== null) {
|
||||||
$subuser = (new subusers_o())->select($subuser_id);
|
$candidate = (new subusers_o())->select($subuser_id);
|
||||||
|
if ($candidate->exists()) {
|
||||||
|
$candidate->getObjectProperties();
|
||||||
|
$subuser = $candidate;
|
||||||
|
}
|
||||||
} elseif ($username !== null) {
|
} elseif ($username !== null) {
|
||||||
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
||||||
}
|
}
|
||||||
if ($subuser === null) {
|
if ($subuser === null) {
|
||||||
$response->error('Subuser not found', 404);
|
$this->subuserAuthFailure();
|
||||||
}
|
}
|
||||||
self::requireParameters(['password']);
|
self::requireParameters(['password']);
|
||||||
$password = (string)self::getParameter('password');
|
$password = (string)self::getParameter('password');
|
||||||
$this->requireSubuserPasswordPolicy($password);
|
|
||||||
try {
|
try {
|
||||||
if (password_verify($password, $subuser->password->value())) {
|
$passwordHash = $subuser->password->value();
|
||||||
|
if (is_string($passwordHash) && $passwordHash !== '' && password_verify($password, $passwordHash)) {
|
||||||
|
$this->clearThrottleAttempt($authThrottleKey);
|
||||||
if ($subuser->isTwoFactorEnabled()) {
|
if ($subuser->isTwoFactorEnabled()) {
|
||||||
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
|
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
|
||||||
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
||||||
@@ -1092,7 +1132,7 @@ class subusersRoute
|
|||||||
$session = $subuser->generateSession();
|
$session = $subuser->generateSession();
|
||||||
$response->success(['session' => $session]);
|
$response->success(['session' => $session]);
|
||||||
} else {
|
} else {
|
||||||
$response->error('Invalid password', 400);
|
$this->subuserAuthFailure();
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error($e->getMessage(), 500);
|
$response->error($e->getMessage(), 500);
|
||||||
@@ -1363,15 +1403,7 @@ class subusersRoute
|
|||||||
(int)$phone
|
(int)$phone
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optionally send SMS with setup link
|
$invite = $this->issueSetupInvite($subuser);
|
||||||
$gatewayAPI = new gatewayapi();
|
|
||||||
if ($gatewayAPI->isEnabled()) {
|
|
||||||
$token = $subuser->generateSetupToken();
|
|
||||||
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
|
||||||
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
|
||||||
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
|
||||||
$gatewayAPI->send($phone_number_array, $message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a pending grant request for the company
|
// Create a pending grant request for the company
|
||||||
$subuser_grants_o = new subuser_grants_o();
|
$subuser_grants_o = new subuser_grants_o();
|
||||||
@@ -1381,7 +1413,7 @@ class subusersRoute
|
|||||||
$response->error('Failed to add subuser grant', 500);
|
$response->error('Failed to add subuser grant', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ class vehiclesRoute
|
|||||||
self::requireParameters([
|
self::requireParameters([
|
||||||
'type',
|
'type',
|
||||||
'reg',
|
'reg',
|
||||||
'wash_subscription',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Determine target customer
|
// Determine target customer
|
||||||
@@ -165,11 +164,14 @@ class vehiclesRoute
|
|||||||
// Validate the parameters
|
// Validate the parameters
|
||||||
self::requireType(self::getParameter('reg'), self::type_string());
|
self::requireType(self::getParameter('reg'), self::type_string());
|
||||||
self::requireType(self::getParameter('type'), self::type_int());
|
self::requireType(self::getParameter('type'), self::type_int());
|
||||||
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
|
$subscription = false;
|
||||||
|
if (self::isParametersSet(['wash_subscription'])) {
|
||||||
|
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
|
||||||
|
$subscription = (bool)self::getParameter('wash_subscription');
|
||||||
|
}
|
||||||
// Get the parameters
|
// Get the parameters
|
||||||
$reg = (string)self::getParameter('reg');
|
$reg = (string)self::getParameter('reg');
|
||||||
$type = (int)self::getParameter('type');
|
$type = (int)self::getParameter('type');
|
||||||
$subscription = (bool)self::getParameter('wash_subscription');
|
|
||||||
$reg = trim($reg);
|
$reg = trim($reg);
|
||||||
|
|
||||||
// Create a new vehicle
|
// Create a new vehicle
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class workerRoute
|
|||||||
// The time should be from 00:00:00 of the start date to 23:59:59 of the end date
|
// The time should be from 00:00:00 of the start date to 23:59:59 of the end date
|
||||||
$date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59
|
$date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59
|
||||||
$date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00
|
$date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00
|
||||||
$department->sendSlackInternalStatisticNotification($date_start, $date_end, [1,2,3,4,5,6,7]);
|
$department->sendSlackInternalStatisticNotification($date_start, $date_end);
|
||||||
$response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]);
|
$response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]);
|
||||||
exit;
|
exit;
|
||||||
// Configuration
|
// Configuration
|
||||||
|
|||||||
@@ -200,6 +200,66 @@ it('logs out and invalidates cached subuser sessions', function (): void {
|
|||||||
->assertMessage('Unauthorized');
|
->assertMessage('Unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes passkey challenges to the requested principal type', function (): void {
|
||||||
|
api_test_covers('POST /auth/passkey/challenge', 'auth');
|
||||||
|
|
||||||
|
api_fixtures()->setModuleConfig('reCAPTCHA', 'enabled', 'false');
|
||||||
|
$user = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Passkey Customer',
|
||||||
|
]);
|
||||||
|
$subuser = api_fixtures()->createSubuser([
|
||||||
|
'username' => 'passkey-driver',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createPasskey([
|
||||||
|
'user_id' => (int)$user['id'],
|
||||||
|
'is_subuser' => false,
|
||||||
|
'credential_id' => 'customer-passkey-credential',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createPasskey([
|
||||||
|
'user_id' => (int)$subuser['id'],
|
||||||
|
'is_subuser' => true,
|
||||||
|
'credential_id' => 'subuser-passkey-credential',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subuserChallenge = api_client()->post('/auth/passkey/challenge', [
|
||||||
|
'principal_type' => 'subuser',
|
||||||
|
'username' => 'passkey-driver',
|
||||||
|
]);
|
||||||
|
$userChallenge = api_client()->post('/auth/passkey/challenge', [
|
||||||
|
'principal_type' => 'user',
|
||||||
|
'customer_number' => (int)$user['customer_number'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subuserChallenge
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
$userChallenge
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$subuserCredentials = $subuserChallenge->data()['publicKey']['allowCredentials'] ?? [];
|
||||||
|
$userCredentials = $userChallenge->data()['publicKey']['allowCredentials'] ?? [];
|
||||||
|
|
||||||
|
$subuserCredentialIds = array_column($subuserCredentials, 'id');
|
||||||
|
$userCredentialIds = array_column($userCredentials, 'id');
|
||||||
|
|
||||||
|
expect($subuserCredentialIds)->toContain('subuser-passkey-credential');
|
||||||
|
expect($subuserCredentialIds)->not->toContain('customer-passkey-credential');
|
||||||
|
expect($userCredentialIds)->toContain('customer-passkey-credential');
|
||||||
|
expect($userCredentialIds)->not->toContain('subuser-passkey-credential');
|
||||||
|
|
||||||
|
$subuserChallengeToken = (string)($subuserChallenge->data()['challenge_token'] ?? '');
|
||||||
|
$userChallengeToken = (string)($userChallenge->data()['challenge_token'] ?? '');
|
||||||
|
if ($subuserChallengeToken !== '') {
|
||||||
|
api_fixtures()->cleanupDeleteWhere('tokens', ['token' => $subuserChallengeToken]);
|
||||||
|
}
|
||||||
|
if ($userChallengeToken !== '') {
|
||||||
|
api_fixtures()->cleanupDeleteWhere('tokens', ['token' => $userChallengeToken]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid logout tokens', function (): void {
|
it('rejects invalid logout tokens', function (): void {
|
||||||
api_test_covers('GET /auth/logout', 'auth');
|
api_test_covers('GET /auth/logout', 'auth');
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h
|
|||||||
->and($artifact->body)
|
->and($artifact->body)
|
||||||
->toContain('<?php');
|
->toContain('<?php');
|
||||||
|
|
||||||
|
$manifest = api_client()->get('/edge-agent/artifacts/manifest.json');
|
||||||
|
expect($manifest->status)->toBe(200);
|
||||||
|
$manifestPayload = json_decode($manifest->body, true);
|
||||||
|
expect($manifestPayload)->toBeArray()
|
||||||
|
->and($manifestPayload['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
|
||||||
|
$manifestArtifacts = array_column((array)($manifestPayload['artifacts'] ?? []), null, 'name');
|
||||||
|
expect($manifestArtifacts)->toHaveKey('agent.php')
|
||||||
|
->and($manifestArtifacts['agent.php']['sha256'] ?? null)->toBe(hash('sha256', $artifact->body));
|
||||||
|
|
||||||
$claimResponse = api_client()->post('/edge-agent/claim', [
|
$claimResponse = api_client()->post('/edge-agent/claim', [
|
||||||
'token' => (string)$installToken['token'],
|
'token' => (string)$installToken['token'],
|
||||||
'hostname' => 'edge-agent-api',
|
'hostname' => 'edge-agent-api',
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
it('serves whole-department expected relay states and records successful physical completion only', function (): void {
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'department' => [
|
||||||
|
'name' => 'Edge Expected State Department',
|
||||||
|
],
|
||||||
|
'relay_ids' => [
|
||||||
|
'entry' => 'expected-entry',
|
||||||
|
'exit' => 'expected-exit',
|
||||||
|
'machine' => 'expected-machine',
|
||||||
|
'program_picker' => 'expected-program',
|
||||||
|
'cleaner' => 'expected-cleaner',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$departmentId = (int)$scenario['department']['id'];
|
||||||
|
$laneId = (int)$scenario['lane']['id'];
|
||||||
|
api_fixtures()->setDepartmentShellyTransportMode($departmentId, 'gateway');
|
||||||
|
$session = api_fixtures()->createEdgeOperatorSession($departmentId, [
|
||||||
|
'modules_selfserve_lane_relay_machine_status_set',
|
||||||
|
'modules_selfserve_lane_relay_machine_cleaner_status_set',
|
||||||
|
]);
|
||||||
|
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'label' => 'Expected State Gateway',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$relayIps = [
|
||||||
|
'expected-entry' => '10.41.0.11',
|
||||||
|
'expected-exit' => '10.41.0.12',
|
||||||
|
'expected-machine' => '10.41.0.13',
|
||||||
|
'expected-program' => '10.41.0.14',
|
||||||
|
'expected-cleaner' => '10.41.0.15',
|
||||||
|
];
|
||||||
|
foreach ($relayIps as $relayId => $ip) {
|
||||||
|
api_fixtures()->createEdgeRelayBinding([
|
||||||
|
'gateway_id' => (int)$gateway['id'],
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'device_id' => 'device-' . $relayId,
|
||||||
|
'local_ip' => $ip,
|
||||||
|
'metadata' => [
|
||||||
|
'device_type' => 'SHELLY_SWITCH',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queueResponse = api_client()->post('/modules/self-serve/lane/hardware/batch/set', [
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'transport' => 'gateway',
|
||||||
|
'commands' => [
|
||||||
|
['target' => 'MACHINE', 'on' => true],
|
||||||
|
['target' => 'CLEANER', 'on' => false],
|
||||||
|
],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$queueResponse
|
||||||
|
->assertStatus(202)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$expectedResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/expected-relay-states', [
|
||||||
|
'agent_token' => (string)$gateway['agent_token'],
|
||||||
|
'wait_seconds' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expectedResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$lanes = $expectedResponse->data()['lanes'] ?? [];
|
||||||
|
expect($lanes)->toHaveKey((string)$laneId);
|
||||||
|
$laneStates = $lanes[(string)$laneId];
|
||||||
|
|
||||||
|
expect(array_keys($laneStates))->toBe([
|
||||||
|
'ENTRANCE_GATE',
|
||||||
|
'EXIT_GATE',
|
||||||
|
'MACHINE',
|
||||||
|
'PROGRAM_PICKER',
|
||||||
|
'CLEANER',
|
||||||
|
])
|
||||||
|
->and($laneStates['MACHINE']['device'])->toBe([
|
||||||
|
'ip' => '10.41.0.13',
|
||||||
|
'id' => 'device-expected-machine',
|
||||||
|
'type' => 'SHELLY_SWITCH',
|
||||||
|
])
|
||||||
|
->and($laneStates['MACHINE']['state'])->toBeTrue()
|
||||||
|
->and($laneStates['MACHINE']['completed'])->toBeNull()
|
||||||
|
->and($laneStates['CLEANER']['state'])->toBeFalse()
|
||||||
|
->and($laneStates['CLEANER']['completed'])->toBeNull()
|
||||||
|
->and($laneStates['ENTRANCE_GATE']['completed'])->not->toBeNull();
|
||||||
|
expect($expectedResponse->data())->toHaveKey('CLEANER')
|
||||||
|
->and($expectedResponse->data()['MACHINE']['device'] ?? null)->toBe($laneStates['MACHINE']['device'])
|
||||||
|
->and($expectedResponse->data()['CLEANER']['completed'])->toBeNull();
|
||||||
|
|
||||||
|
$machineUpdated = (string)$laneStates['MACHINE']['updated'];
|
||||||
|
$cleanerUpdated = (string)$laneStates['CLEANER']['updated'];
|
||||||
|
|
||||||
|
$resultResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/relay-state-results', [
|
||||||
|
'agent_token' => (string)$gateway['agent_token'],
|
||||||
|
'results' => [
|
||||||
|
[
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'role' => 'MACHINE',
|
||||||
|
'state' => true,
|
||||||
|
'updated' => $machineUpdated,
|
||||||
|
'ok' => true,
|
||||||
|
'completed' => '2026-07-01T09:10:00+00:00',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'role' => 'CLEANER',
|
||||||
|
'state' => false,
|
||||||
|
'updated' => $cleanerUpdated,
|
||||||
|
'ok' => false,
|
||||||
|
'skipped' => true,
|
||||||
|
'error' => 'Relay offline',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resultResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($resultResponse->data()['summary'] ?? [])
|
||||||
|
->toHaveKey('completed', 1)
|
||||||
|
->toHaveKey('skipped', 1)
|
||||||
|
->toHaveKey('failed', 0);
|
||||||
|
|
||||||
|
$afterResult = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/expected-relay-states', [
|
||||||
|
'agent_token' => (string)$gateway['agent_token'],
|
||||||
|
'wait_seconds' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$afterResult
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$afterLaneStates = $afterResult->data()['lanes'][(string)$laneId] ?? [];
|
||||||
|
expect($afterLaneStates['MACHINE']['completed'] ?? null)->not->toBeNull()
|
||||||
|
->and($afterLaneStates['CLEANER']['completed'] ?? null)->toBeNull()
|
||||||
|
->and($afterLaneStates['CLEANER']['last_error'] ?? null)->toBe('Relay offline');
|
||||||
|
|
||||||
|
$staleResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/relay-state-results', [
|
||||||
|
'agent_token' => (string)$gateway['agent_token'],
|
||||||
|
'results' => [[
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'role' => 'CLEANER',
|
||||||
|
'state' => false,
|
||||||
|
'updated' => '2000-01-01 00:00:00',
|
||||||
|
'ok' => true,
|
||||||
|
'completed' => '2026-07-01T09:11:00+00:00',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($staleResponse->data()['summary']['stale'] ?? null)->toBe(1)
|
||||||
|
->and($staleResponse->data()['rejected'][0]['reason'] ?? null)->toBe('STALE_STATE');
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\limited_backoffice_service;
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function limited_backoffice_manager_session(array $departmentIds, array $extraPermissions = []): array
|
||||||
|
{
|
||||||
|
$permissions = [
|
||||||
|
limited_backoffice_service::PERMISSION_ACCESS,
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_PRICES,
|
||||||
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
|
||||||
|
];
|
||||||
|
foreach ($departmentIds as $departmentId) {
|
||||||
|
$permissions[] = 'department_access_' . (int)$departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
|
||||||
|
{
|
||||||
|
$statement = api_test_runtime()->db()->prepare(
|
||||||
|
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
|
||||||
|
);
|
||||||
|
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'product_id' => $productId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function limited_backoffice_price_value(int $departmentId, int $productId): ?int
|
||||||
|
{
|
||||||
|
$row = api_test_runtime()->queryOne(
|
||||||
|
'SELECT `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId .
|
||||||
|
' AND `product_id` = ' . $productId . ' LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $row === null ? null : (int)$row['price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function limited_backoffice_cleanup_created_employee(int $employeeId): void
|
||||||
|
{
|
||||||
|
$row = api_test_runtime()->queryOne(
|
||||||
|
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||||
|
);
|
||||||
|
$groupId = (int)($row['managed_group_id'] ?? 0);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $employeeId]);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('tokens', ['user_id' => $employeeId]);
|
||||||
|
api_fixtures()->cleanupDeleteById('users', $employeeId);
|
||||||
|
if ($groupId > 0) {
|
||||||
|
api_fixtures()->cleanupDeleteWhere('groups_permissions', ['group_id' => $groupId]);
|
||||||
|
api_fixtures()->cleanupDeleteById('groups', $groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limited_backoffice_without_users_deleted_at(callable $callback): void
|
||||||
|
{
|
||||||
|
$db = api_test_runtime()->db();
|
||||||
|
$column = $db->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'");
|
||||||
|
if ($column === false) {
|
||||||
|
throw new RuntimeException('Unable to inspect users.deleted_at test column.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadColumn = (int)$column->num_rows > 0;
|
||||||
|
if ($hadColumn) {
|
||||||
|
$db->query('ALTER TABLE `users` DROP COLUMN `deleted_at`');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callback();
|
||||||
|
} finally {
|
||||||
|
if ($hadColumn) {
|
||||||
|
$db->query('ALTER TABLE `users` ADD COLUMN `deleted_at` DATETIME NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limited_backoffice_without_department_prices_updated_at(callable $callback): void
|
||||||
|
{
|
||||||
|
$db = api_test_runtime()->db();
|
||||||
|
$column = $db->query("SHOW COLUMNS FROM `product_department_prices` LIKE 'updated_at'");
|
||||||
|
if ($column === false) {
|
||||||
|
throw new RuntimeException('Unable to inspect product_department_prices.updated_at test column.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadColumn = (int)$column->num_rows > 0;
|
||||||
|
if ($hadColumn) {
|
||||||
|
$db->query('ALTER TABLE `product_department_prices` DROP COLUMN `updated_at`');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callback();
|
||||||
|
} finally {
|
||||||
|
if ($hadColumn) {
|
||||||
|
$db->query(
|
||||||
|
'ALTER TABLE `product_department_prices`
|
||||||
|
ADD COLUMN `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
AFTER `created_at`'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists and updates explicit prices only for assigned departments', function (): void {
|
||||||
|
api_test_covers('GET /limited-backoffice/departments', 'happy');
|
||||||
|
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||||
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||||
|
|
||||||
|
$productDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `products` LIKE 'deleted_at'");
|
||||||
|
expect($productDeletedAtColumn)->not->toBeFalse();
|
||||||
|
expect((int)$productDeletedAtColumn->num_rows)->toBe(0);
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Own']);
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Prices Other']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Limited Washes']);
|
||||||
|
$product = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Limited Wash Product',
|
||||||
|
'category' => $category['id'],
|
||||||
|
'price' => 98765,
|
||||||
|
]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||||
|
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 1234);
|
||||||
|
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
|
||||||
|
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
|
||||||
|
$departments
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect(array_column($departments->data(), 'id'))
|
||||||
|
->toContain((int)$department['id'])
|
||||||
|
->not->toContain((int)$otherDepartment['id']);
|
||||||
|
|
||||||
|
$prices = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||||
|
$prices
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($prices->body)->not->toContain('98765');
|
||||||
|
expect($prices->data()['categories'][0]['products'][0])
|
||||||
|
->toMatchArray([
|
||||||
|
'id' => (int)$product['id'],
|
||||||
|
'name' => 'Limited Wash Product',
|
||||||
|
'price' => 1234,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$product['id'], 'price' => '2222'],
|
||||||
|
],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$updated
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(2222);
|
||||||
|
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates department prices when the price table has no updated_at column', function (): void {
|
||||||
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'schema compatibility');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Legacy Schema']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Limited Prices Legacy Category']);
|
||||||
|
$products = [
|
||||||
|
api_fixtures()->createProduct(['name' => 'Legacy Price One', 'category' => $category['id']]),
|
||||||
|
api_fixtures()->createProduct(['name' => 'Legacy Price Two', 'category' => $category['id']]),
|
||||||
|
api_fixtures()->createProduct(['name' => 'Legacy Price Three', 'category' => $category['id']]),
|
||||||
|
];
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
|
||||||
|
foreach ($products as $index => $product) {
|
||||||
|
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100 + $index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
limited_backoffice_without_department_prices_updated_at(function () use ($department, $products, $session): void {
|
||||||
|
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$products[0]['id'], 'price' => '999999'],
|
||||||
|
['product_id' => (int)$products[1]['id'], 'price' => '999999'],
|
||||||
|
['product_id' => (int)$products[2]['id'], 'price' => '99999'],
|
||||||
|
],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$updated
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[0]['id']))->toBe(999999);
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[1]['id']))->toBe(999999);
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[2]['id']))->toBe(99999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
|
||||||
|
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||||
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Scope Own']);
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Scope Other']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Limited Scope Category']);
|
||||||
|
$otherCategory = api_fixtures()->createCategory(['name' => 'Limited Scope Other Category']);
|
||||||
|
$product = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||||
|
$outsideProduct = api_fixtures()->createProduct(['category' => $otherCategory['id']]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$otherCategory['id']);
|
||||||
|
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100);
|
||||||
|
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$outsideProduct['id'], 200);
|
||||||
|
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
api_client()->get('/limited-backoffice/departments/' . (int)$otherDepartment['id'] . '/prices', $session['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'department_id' => (int)$otherDepartment['id'],
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$product['id'], 'price' => 111],
|
||||||
|
],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Department ID in body does not match the route.');
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$product['id'], 'price' => 111],
|
||||||
|
['product_id' => (int)$outsideProduct['id'], 'price' => 111],
|
||||||
|
],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Product is not available for this department.');
|
||||||
|
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(100);
|
||||||
|
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$outsideProduct['id']))->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails price setup gaps without exposing product defaults', function (): void {
|
||||||
|
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'failure');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Setup Gap']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Limited Setup Gap Category']);
|
||||||
|
$product = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Setup Gap Product',
|
||||||
|
'category' => $category['id'],
|
||||||
|
'price' => 87654,
|
||||||
|
]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||||
|
$response
|
||||||
|
->assertStatus(409)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Department price setup is incomplete.');
|
||||||
|
|
||||||
|
expect($response->body)->not->toContain('87654');
|
||||||
|
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
|
||||||
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Invalid Prices']);
|
||||||
|
$category = api_fixtures()->createCategory(['name' => 'Limited Invalid Prices Category']);
|
||||||
|
$firstProduct = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||||
|
$secondProduct = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||||
|
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||||
|
limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 100);
|
||||||
|
limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 200);
|
||||||
|
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
foreach ([null, '', 'abc', -1] as $invalidPrice) {
|
||||||
|
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||||
|
['product_id' => (int)$secondProduct['id'], 'price' => $invalidPrice],
|
||||||
|
],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||||
|
'prices' => [
|
||||||
|
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||||
|
],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Price is required for every department product.');
|
||||||
|
|
||||||
|
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates updates lists and deactivates scoped employees without exposing raw permissions', function (): void {
|
||||||
|
limited_backoffice_without_users_deleted_at(function (): void {
|
||||||
|
api_test_covers('GET /limited-backoffice/roles', 'happy');
|
||||||
|
api_test_covers('GET /limited-backoffice/employees', 'happy');
|
||||||
|
api_test_covers('POST /limited-backoffice/employees', 'happy');
|
||||||
|
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'happy');
|
||||||
|
api_test_covers('DELETE /limited-backoffice/employees/{employeeId}', 'happy');
|
||||||
|
|
||||||
|
$usersDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'");
|
||||||
|
expect($usersDeletedAtColumn)->not->toBeFalse();
|
||||||
|
expect((int)$usersDeletedAtColumn->num_rows)->toBe(0);
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
|
||||||
|
$roles
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
||||||
|
$rolesByKey = array_column($roles->data(), null, 'key');
|
||||||
|
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
|
||||||
|
[
|
||||||
|
'key' => 'account',
|
||||||
|
'capabilities' => ['sign_in', 'view_own_permissions'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
|
||||||
|
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
|
||||||
|
'open_limited_backoffice',
|
||||||
|
'manage_department_prices',
|
||||||
|
'manage_employee_access',
|
||||||
|
]);
|
||||||
|
expect($roles->body)->not->toContain('department_access_');
|
||||||
|
$rolePayload = $roles->data();
|
||||||
|
$rolePayloadStrings = [];
|
||||||
|
array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$rolePayloadStrings[] = $value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
foreach ([
|
||||||
|
'list_orders',
|
||||||
|
'add_order',
|
||||||
|
'edit_order',
|
||||||
|
'delete_order',
|
||||||
|
'list_order_items',
|
||||||
|
'add_order_items',
|
||||||
|
'edit_order_items',
|
||||||
|
'delete_order_items',
|
||||||
|
'charge_order',
|
||||||
|
'list_bookings',
|
||||||
|
'list_own_bookings',
|
||||||
|
'edit_bookings',
|
||||||
|
'add_booking',
|
||||||
|
'complete_bookings',
|
||||||
|
'resend_booking_confirmations',
|
||||||
|
'department_timebookings_entries_get',
|
||||||
|
'department_timebookings_entries_post',
|
||||||
|
'department_timebookings_entries_put',
|
||||||
|
'statistics_orders_new',
|
||||||
|
'statistics_bookings_new',
|
||||||
|
'limited_backoffice_access',
|
||||||
|
'limited_backoffice_prices_manage',
|
||||||
|
'limited_backoffice_employees_manage',
|
||||||
|
] as $rawPermission) {
|
||||||
|
expect($rolePayloadStrings)->not->toContain($rawPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = api_client()->post('/limited-backoffice/employees', [
|
||||||
|
'display_name' => 'Limited Cashier',
|
||||||
|
'email' => 'limited-cashier@example.test',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'cashier',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$created
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||||
|
expect($employeeId)->toBeGreaterThan(0);
|
||||||
|
limited_backoffice_cleanup_created_employee($employeeId);
|
||||||
|
expect($created->body)->not->toContain('department_access_');
|
||||||
|
expect($created->body)->not->toContain('permissions');
|
||||||
|
|
||||||
|
$groupRow = api_test_runtime()->queryOne(
|
||||||
|
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||||
|
);
|
||||||
|
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
|
||||||
|
$permissionRows = api_test_runtime()->db()->query(
|
||||||
|
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
|
||||||
|
)->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$permissions = array_column($permissionRows, 'permission');
|
||||||
|
expect($permissions)
|
||||||
|
->toContain('department_access_' . (int)$department['id'])
|
||||||
|
->toContain('add_order')
|
||||||
|
->not->toContain('superuser');
|
||||||
|
|
||||||
|
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||||
|
'display_name' => 'Limited Lead',
|
||||||
|
'role_key' => 'operations_lead',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
], $session['headers']);
|
||||||
|
$updated
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
||||||
|
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
||||||
|
|
||||||
|
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||||
|
$ids = array_column($list->data(), 'id');
|
||||||
|
expect($ids)->toContain($employeeId);
|
||||||
|
expect($list->body)->not->toContain('department_access_');
|
||||||
|
|
||||||
|
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||||
|
$deactivated
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($deactivated->data()['active'] ?? true)->toBeFalse();
|
||||||
|
$userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId);
|
||||||
|
expect($userRow)->not->toBeNull();
|
||||||
|
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
|
||||||
|
expect($userRow['password'])->toBeNull();
|
||||||
|
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
|
||||||
|
$employeeRow = api_test_runtime()->queryOne(
|
||||||
|
'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||||
|
);
|
||||||
|
expect($employeeRow['deactivated_at'] ?? null)->not->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
||||||
|
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||||
|
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Own']);
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Employee Other']);
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
'display_name' => 'Outside Employee',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'cashier',
|
||||||
|
'department_ids' => [(int)$otherDepartment['id']],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
'display_name' => 'Raw Employee',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'cashier',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
'permissions' => ['superuser'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Raw permission and group assignment is not allowed.');
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
'display_name' => 'Unknown Role Employee',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'superuser',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Unknown role.');
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/employees/' . (int)$session['user']['id'], [
|
||||||
|
'display_name' => 'Self Edit',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Managers cannot edit themselves.');
|
||||||
|
|
||||||
|
$superuser = api_fixtures()->createUser(['group_id' => 1]);
|
||||||
|
api_test_runtime()->db()->query(
|
||||||
|
'INSERT INTO `limited_backoffice_employees`
|
||||||
|
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
|
||||||
|
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
|
||||||
|
);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/employees/' . (int)$superuser['id'], [
|
||||||
|
'display_name' => 'Edited Superuser',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Cannot manage superuser accounts.');
|
||||||
|
|
||||||
|
$sharedGroup = api_fixtures()->createGroup();
|
||||||
|
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
|
||||||
|
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
|
||||||
|
$departmentJson = '[' . (int)$department['id'] . ']';
|
||||||
|
api_test_runtime()->db()->query(
|
||||||
|
'INSERT INTO `limited_backoffice_employees`
|
||||||
|
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
|
||||||
|
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
|
||||||
|
);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/employees/' . (int)$firstSharedUser['id'], [
|
||||||
|
'display_name' => 'Edited Shared Group',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Cannot manage shared groups.');
|
||||||
|
});
|
||||||
@@ -15,7 +15,6 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
'reference' => 'NOTE-REQUIRED',
|
'reference' => 'NOTE-REQUIRED',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902701,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
@@ -49,6 +48,85 @@ it('requires notes when adding the extraordinary chemistry product to an order',
|
|||||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only allows tankcleaning products for only tankcleaning customers', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer_rules');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Only Tankcleaning Customer']);
|
||||||
|
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning');
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ONLY-TANK',
|
||||||
|
]);
|
||||||
|
$washProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Forvogn',
|
||||||
|
'price' => 649,
|
||||||
|
'category' => 4,
|
||||||
|
]);
|
||||||
|
$tankCleaningProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'price' => 299,
|
||||||
|
'category' => 5,
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||||
|
|
||||||
|
api_client()
|
||||||
|
->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $washProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage(\classes\customer_order_product_policy::ONLY_TANKCLEANING_MESSAGE);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $tankCleaningProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$tankCleaningProduct['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows non-tankcleaning products for customers without the only tankcleaning attribute', function (): void {
|
||||||
|
api_test_covers('POST /order/items', 'customer_rules');
|
||||||
|
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Regular Order Item Customer']);
|
||||||
|
$department = api_fixtures()->createDepartment();
|
||||||
|
$order = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'REGULAR-WASH',
|
||||||
|
]);
|
||||||
|
$washProduct = api_fixtures()->createProduct([
|
||||||
|
'name' => 'Forvogn',
|
||||||
|
'price' => 649,
|
||||||
|
'category' => 4,
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order/items', [
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'product_id' => $washProduct['id'],
|
||||||
|
'quantity' => 1,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect((int)($response->data()['product_id'] ?? 0))->toBe((int)$washProduct['id']);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||||
api_test_covers('PUT /order/items', 'validation');
|
api_test_covers('PUT /order/items', 'validation');
|
||||||
|
|
||||||
@@ -62,7 +140,6 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'reference' => 'NOTE-EDIT',
|
'reference' => 'NOTE-EDIT',
|
||||||
]);
|
]);
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902702,
|
|
||||||
'name' => 'API Note Required Product',
|
'name' => 'API Note Required Product',
|
||||||
'price' => 199,
|
'price' => 199,
|
||||||
'requires_note' => 1,
|
'requires_note' => 1,
|
||||||
@@ -75,7 +152,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
|||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'notes' => 'Initial note',
|
'notes' => 'Initial note',
|
||||||
]);
|
]);
|
||||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||||
|
|
||||||
api_client()
|
api_client()
|
||||||
->put('/order/items', [
|
->put('/order/items', [
|
||||||
@@ -95,7 +172,6 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
|||||||
api_test_covers('GET /products', 'happy');
|
api_test_covers('GET /products', 'happy');
|
||||||
|
|
||||||
$product = api_fixtures()->createProduct([
|
$product = api_fixtures()->createProduct([
|
||||||
'id' => 902703,
|
|
||||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||||
'price' => 299,
|
'price' => 299,
|
||||||
'requires_note' => 0,
|
'requires_note' => 0,
|
||||||
|
|||||||
@@ -264,6 +264,55 @@ it('derives allowed services from published v2 config task snapshots when no ses
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps questions visible but disables machine services and tasks when machine wash is globally disabled', function (): void {
|
||||||
|
$group = api_fixtures()->createGroup([], [
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'customer' => ['group_id' => $group['id']],
|
||||||
|
'department_selfserve_enabled' => true,
|
||||||
|
'lane_selfserve_enabled' => true,
|
||||||
|
'machine_wash_enabled' => false,
|
||||||
|
]);
|
||||||
|
$headers = api_fixtures()->bearerHeaders(
|
||||||
|
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
||||||
|
);
|
||||||
|
$laneId = (int)$scenario['lane']['id'];
|
||||||
|
$reg = (string)$scenario['vehicle']['reg'];
|
||||||
|
$productId = (int)$scenario['product']['id'];
|
||||||
|
$machineTaskId = (int)$scenario['tasks'][1]['id'];
|
||||||
|
|
||||||
|
$allowedResponse = api_client()
|
||||||
|
->get(
|
||||||
|
'/department/selfserve/vehicle/allowed?lane_id=' . $laneId
|
||||||
|
. '®=' . urlencode($reg)
|
||||||
|
. '&vehicle_type=' . $productId,
|
||||||
|
$headers
|
||||||
|
)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect($allowedResponse->data()['questions'] ?? [])->not->toBeEmpty()
|
||||||
|
->and($allowedResponse->data()['allowed_services'] ?? [])->not->toContain('MACHINE')
|
||||||
|
->and($allowedResponse->data()['machine_available'] ?? null)->toBeFalse()
|
||||||
|
->and($allowedResponse->data()['machine_wash_enabled'] ?? null)->toBeFalse()
|
||||||
|
->and($allowedResponse->data()['allowed'] ?? null)->toBeFalse();
|
||||||
|
|
||||||
|
foreach ($allowedResponse->data()['tasks'] ?? [] as $task) {
|
||||||
|
expect($task['services'] ?? [])->not->toContain('MACHINE');
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceResponse = api_client()
|
||||||
|
->post('/modules/self-serve/lane/services/allowed', [
|
||||||
|
'lane_id' => $laneId,
|
||||||
|
'task_ids' => [$machineTaskId],
|
||||||
|
], $headers)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect($serviceResponse->data()['allowed_services'] ?? [])->not->toContain('MACHINE');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps long generated task descriptions when refreshing vehicle eligibility snapshots', function (): void {
|
it('keeps long generated task descriptions when refreshing vehicle eligibility snapshots', function (): void {
|
||||||
$group = api_fixtures()->createGroup([], [
|
$group = api_fixtures()->createGroup([], [
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ it('lists subusers when an existing grant has legacy zero permissions', function
|
|||||||
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
|
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the same password policy for subuser setup and password auth', function (): void {
|
it('enforces the password policy for setup without rejecting legacy valid passwords at login', function (): void {
|
||||||
api_test_covers('POST /subusers/setup', 'failure');
|
api_test_covers('POST /subusers/setup', 'failure');
|
||||||
api_test_covers('POST /subusers/auth/password', 'failure');
|
api_test_covers('POST /subusers/auth/password', 'happy');
|
||||||
|
|
||||||
$subuser = api_fixtures()->createSubuser();
|
$subuser = api_fixtures()->createSubuser([
|
||||||
|
'password_plaintext' => 'invalidpassword',
|
||||||
|
]);
|
||||||
|
|
||||||
$setupResponse = api_client()->post('/subusers/setup', [
|
$setupResponse = api_client()->post('/subusers/setup', [
|
||||||
'token' => 'policy-test-token',
|
'token' => 'policy-test-token',
|
||||||
@@ -68,10 +70,114 @@ it('uses the same password policy for subuser setup and password auth', function
|
|||||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
||||||
|
|
||||||
$authResponse
|
$authResponse
|
||||||
->assertStatus(400)
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($authResponse->data()['session'] ?? null)->toBeString();
|
||||||
|
(new \objects\subusers_o())->invalidateSessionToken((string)$authResponse->data()['session']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a generic error for invalid subuser credentials', function (): void {
|
||||||
|
api_test_covers('POST /subusers/auth/password', 'failure');
|
||||||
|
|
||||||
|
$response = api_client()->post('/subusers/auth/password', [
|
||||||
|
'subuser_id' => 999999999,
|
||||||
|
'password' => 'whatever',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(401)
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
->assertMessage('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires subuser management access before exposing permission nodes', function (): void {
|
||||||
|
api_test_covers('GET /subusers/permission-nodes', 'auth');
|
||||||
|
|
||||||
|
$unauthenticated = api_client()->get('/subusers/permission-nodes');
|
||||||
|
$unauthenticated
|
||||||
|
->assertStatus(401)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['list_own_subusers']);
|
||||||
|
$authorized = api_client()->get('/subusers/permission-nodes', $session['headers']);
|
||||||
|
$authorized
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($authorized->data())->toBeArray()->not->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects customer subuser listing without own-scope permission', function (): void {
|
||||||
|
api_test_covers('GET /subusers', 'auth');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['user']);
|
||||||
|
$response = api_client()->get('/subusers?page=1&limit=5', $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces older setup tokens when a new setup token is generated', function (): void {
|
||||||
|
$subuser = api_fixtures()->createSubuser([
|
||||||
|
'password_plaintext' => null,
|
||||||
|
'name' => 'Pending Setup Driver',
|
||||||
|
]);
|
||||||
|
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
|
||||||
|
$subuserObject->getObjectProperties();
|
||||||
|
|
||||||
|
$firstToken = $subuserObject->generateSetupToken();
|
||||||
|
$secondToken = $subuserObject->generateSetupToken();
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect((new \objects\subusers_o())->getSubuserBySetupToken($firstToken))->toBeNull();
|
||||||
|
expect((new \objects\subusers_o())->getSubuserBySetupToken($secondToken))->not->toBeNull();
|
||||||
|
} finally {
|
||||||
|
(new \objects\subusers_o())->invalidateSetupToken($secondToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates setup identifiers before setting the driver password', function (): void {
|
||||||
|
api_test_covers('POST /subusers/setup', 'failure');
|
||||||
|
|
||||||
|
$existing = api_fixtures()->createSubuser([
|
||||||
|
'username' => 'existing-driver-setup',
|
||||||
|
]);
|
||||||
|
$pending = api_fixtures()->createSubuser([
|
||||||
|
'password_plaintext' => null,
|
||||||
|
'username' => 'pending-driver-setup',
|
||||||
|
'name' => 'Pending Driver',
|
||||||
|
]);
|
||||||
|
$pendingObject = (new \objects\subusers_o())->select((int)$pending['id']);
|
||||||
|
$pendingObject->getObjectProperties();
|
||||||
|
$token = $pendingObject->generateSetupToken();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = api_client()->post('/subusers/setup', [
|
||||||
|
'token' => $token,
|
||||||
|
'name' => 'Pending Driver',
|
||||||
|
'username' => $existing['username'],
|
||||||
|
'password' => 'ValidPass123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Account already exists with this username');
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('subusers', (int)$pending['id']);
|
||||||
|
expect($row['password'] ?? null)->toBeNull();
|
||||||
|
expect((new \objects\subusers_o())->getSubuserBySetupToken($token))->not->toBeNull();
|
||||||
|
} finally {
|
||||||
|
(new \objects\subusers_o())->invalidateSetupToken($token);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists chauffeur grants across customers for superusers', function (): void {
|
it('lists chauffeur grants across customers for superusers', function (): void {
|
||||||
|
|||||||
@@ -4,6 +4,106 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
usesApiSuite();
|
usesApiSuite();
|
||||||
|
|
||||||
|
it('defaults wash subscriptions to false when a customer creates a vehicle without the field', function (): void {
|
||||||
|
api_test_covers('POST /vehicles', 'happy');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['add_vehicle']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/vehicles', [
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'DEFAULTOWN',
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$vehicleId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($vehicleId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('customer_vehicles', $vehicleId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)$row['customer_id'])->toBe((int)$session['user']['customer_number']);
|
||||||
|
expect((int)$row['wash_subscription'])->toBe(0);
|
||||||
|
expect($response->data())->toMatchArray([
|
||||||
|
'id' => $vehicleId,
|
||||||
|
'customer_id' => (int)$session['user']['customer_number'],
|
||||||
|
'reg' => 'DEFAULTOWN',
|
||||||
|
'wash_subscription' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults wash subscriptions to false when a superuser creates a vehicle for another customer without the field', function (): void {
|
||||||
|
api_test_covers('POST /vehicles', 'happy');
|
||||||
|
|
||||||
|
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Vehicle Default Target Customer']);
|
||||||
|
$session = api_fixtures()->createUserSession(['add_vehicle_other']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/vehicles', [
|
||||||
|
'customer_id' => $targetCustomer['customer_number'],
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'DEFAULTSU',
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$vehicleId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($vehicleId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('customer_vehicles', $vehicleId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)$row['customer_id'])->toBe((int)$targetCustomer['customer_number']);
|
||||||
|
expect((int)$row['wash_subscription'])->toBe(0);
|
||||||
|
expect($response->data())->toMatchArray([
|
||||||
|
'id' => $vehicleId,
|
||||||
|
'customer_id' => (int)$targetCustomer['customer_number'],
|
||||||
|
'reg' => 'DEFAULTSU',
|
||||||
|
'wash_subscription' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honors an explicit wash subscription true value on vehicle create', function (): void {
|
||||||
|
api_test_covers('POST /vehicles', 'happy');
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession(['add_vehicle']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/vehicles', [
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'EXPLICITON',
|
||||||
|
'wash_subscription' => true,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$vehicleId = (int)($response->data()['id'] ?? 0);
|
||||||
|
expect($vehicleId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$row = api_fixtures()->fetchRowById('customer_vehicles', $vehicleId);
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect((int)$row['wash_subscription'])->toBe(1);
|
||||||
|
expect($response->data())->toMatchArray([
|
||||||
|
'id' => $vehicleId,
|
||||||
|
'reg' => 'EXPLICITON',
|
||||||
|
'wash_subscription' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
|
||||||
|
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the newest vehicle last_order_id that still has order items', function (): void {
|
it('returns the newest vehicle last_order_id that still has order items', function (): void {
|
||||||
api_test_covers('GET /vehicles', 'happy');
|
api_test_covers('GET /vehicles', 'happy');
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,32 @@ final class ApiFixtures
|
|||||||
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
|
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setDepartmentShellyTransportMode(int $departmentId, string $mode): void
|
||||||
|
{
|
||||||
|
if ($departmentId <= 0) {
|
||||||
|
throw new RuntimeException('Department Shelly transport fixtures require a positive department id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = strtolower(trim($mode));
|
||||||
|
if (!in_array($mode, ['cloud', 'gateway'], true)) {
|
||||||
|
throw new RuntimeException('Invalid Shelly transport fixture mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$conditions = [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'variable' => 'shelly_transport_mode',
|
||||||
|
];
|
||||||
|
$this->deleteWhereIfPossible('department_variables', $conditions);
|
||||||
|
$this->cleanupDeleteWhere('department_variables', $conditions);
|
||||||
|
|
||||||
|
$variableId = $this->insertRowWithExistingColumns('department_variables', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'variable' => 'shelly_transport_mode',
|
||||||
|
'value' => $mode,
|
||||||
|
]);
|
||||||
|
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
|
||||||
|
}
|
||||||
|
|
||||||
public function setLaneSelfServeEnabled(int $laneId, bool $enabled): void
|
public function setLaneSelfServeEnabled(int $laneId, bool $enabled): void
|
||||||
{
|
{
|
||||||
if ($laneId <= 0) {
|
if ($laneId <= 0) {
|
||||||
@@ -544,6 +570,11 @@ final class ApiFixtures
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->setModuleConfig('selfserve', 'enabled', 'true');
|
$this->setModuleConfig('selfserve', 'enabled', 'true');
|
||||||
|
$this->setModuleConfig(
|
||||||
|
'selfserve',
|
||||||
|
'machine_wash_enabled',
|
||||||
|
(bool)($overrides['machine_wash_enabled'] ?? true) ? 'true' : 'false'
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'department' => $department,
|
'department' => $department,
|
||||||
@@ -885,14 +916,16 @@ final class ApiFixtures
|
|||||||
public function createSubuser(array $attributes = []): array
|
public function createSubuser(array $attributes = []): array
|
||||||
{
|
{
|
||||||
$username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix())));
|
$username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix())));
|
||||||
$passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!');
|
$passwordPlaintext = array_key_exists('password_plaintext', $attributes)
|
||||||
|
? $attributes['password_plaintext']
|
||||||
|
: 'Secret123!';
|
||||||
$name = (string)($attributes['name'] ?? 'API Subuser');
|
$name = (string)($attributes['name'] ?? 'API Subuser');
|
||||||
$email = (string)($attributes['email'] ?? ($username . '@example.test'));
|
$email = (string)($attributes['email'] ?? ($username . '@example.test'));
|
||||||
$now = $this->now();
|
$now = $this->now();
|
||||||
|
|
||||||
$subuserId = $this->insertRow('subusers', [
|
$subuserId = $this->insertRow('subusers', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT),
|
'password' => $passwordPlaintext === null ? null : password_hash((string)$passwordPlaintext, PASSWORD_DEFAULT),
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'phone_country_code' => 45,
|
'phone_country_code' => 45,
|
||||||
@@ -1033,6 +1066,42 @@ final class ApiFixtures
|
|||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createPasskey(array $attributes): array
|
||||||
|
{
|
||||||
|
$userId = (int)($attributes['user_id'] ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
throw new RuntimeException('Passkey fixtures require user_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentialId = (string)($attributes['credential_id'] ?? ('credential-' . strtolower($this->uniqueSuffix())));
|
||||||
|
$passkeyId = $this->insertRow('passkeys', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'is_subuser' => !empty($attributes['is_subuser']) ? 1 : 0,
|
||||||
|
'credential_id' => $credentialId,
|
||||||
|
'public_key' => $attributes['public_key'] ?? str_repeat('A', 64),
|
||||||
|
'algorithm' => $attributes['algorithm'] ?? 'ES256',
|
||||||
|
'transports' => json_encode($attributes['transports'] ?? ['internal'], JSON_UNESCAPED_SLASHES),
|
||||||
|
'sign_count' => (int)($attributes['sign_count'] ?? 0),
|
||||||
|
'backup_state' => json_encode($attributes['backup_state'] ?? new \stdClass(), JSON_UNESCAPED_SLASHES),
|
||||||
|
'name' => $attributes['name'] ?? 'API test passkey',
|
||||||
|
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||||
|
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||||
|
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cleanup->add(fn() => $this->deleteById('passkeys', $passkeyId));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $passkeyId,
|
||||||
|
'credential_id' => $credentialId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function addCustomerAttribute(int $userId, string $attribute): int
|
public function addCustomerAttribute(int $userId, string $attribute): int
|
||||||
{
|
{
|
||||||
$attributeId = $this->insertRow('customer_attributes', [
|
$attributeId = $this->insertRow('customer_attributes', [
|
||||||
@@ -1258,6 +1327,10 @@ final class ApiFixtures
|
|||||||
*/
|
*/
|
||||||
public function createClaimedEdgeGateway(array $attributes): array
|
public function createClaimedEdgeGateway(array $attributes): array
|
||||||
{
|
{
|
||||||
|
if (class_exists(\classes\edge_gateway_schema_bootstrap::class)) {
|
||||||
|
\classes\edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
$departmentId = (int)($attributes['department_id'] ?? 0);
|
$departmentId = (int)($attributes['department_id'] ?? 0);
|
||||||
if ($departmentId <= 0) {
|
if ($departmentId <= 0) {
|
||||||
throw new RuntimeException('Claimed edge gateways require department_id.');
|
throw new RuntimeException('Claimed edge gateways require department_id.');
|
||||||
@@ -1312,6 +1385,7 @@ final class ApiFixtures
|
|||||||
$this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]);
|
||||||
$this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]);
|
||||||
$this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]);
|
||||||
|
$this->deleteWhere('edge_gateway_expected_relay_states', ['gateway_id' => $gatewayId]);
|
||||||
$this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]);
|
||||||
$this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]);
|
||||||
$this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]);
|
$this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]);
|
||||||
@@ -1329,6 +1403,52 @@ final class ApiFixtures
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createEdgeRelayBinding(array $attributes): array
|
||||||
|
{
|
||||||
|
if (class_exists(\classes\edge_gateway_schema_bootstrap::class)) {
|
||||||
|
\classes\edge_gateway_schema_bootstrap::ensureTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||||
|
$departmentId = (int)($attributes['department_id'] ?? 0);
|
||||||
|
$relayId = trim((string)($attributes['relay_id'] ?? ''));
|
||||||
|
if ($gatewayId <= 0 || $departmentId <= 0 || $relayId === '') {
|
||||||
|
throw new RuntimeException('Edge relay bindings require gateway_id, department_id, and relay_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindingId = $this->insertRow('edge_gateway_relay_bindings', [
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
'device_id' => (string)($attributes['device_id'] ?? $relayId),
|
||||||
|
'local_ip' => $attributes['local_ip'] ?? '10.31.0.' . max(2, $gatewayId % 250),
|
||||||
|
'channel' => $attributes['channel'] ?? 0,
|
||||||
|
'fallback_mode' => (string)($attributes['fallback_mode'] ?? 'LOCAL_ONLY'),
|
||||||
|
'binding_source' => (string)($attributes['binding_source'] ?? 'TEST'),
|
||||||
|
'approved_by' => $attributes['approved_by'] ?? null,
|
||||||
|
'approved_at' => $attributes['approved_at'] ?? $this->now(),
|
||||||
|
'metadata_json' => isset($attributes['metadata']) && is_array($attributes['metadata'])
|
||||||
|
? (array)$attributes['metadata']
|
||||||
|
: ['device_type' => 'SHELLY_SWITCH'],
|
||||||
|
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||||
|
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||||
|
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_relay_bindings', $bindingId));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $bindingId,
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'relay_id' => $relayId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $attributes
|
* @param array<string, mixed> $attributes
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -1628,6 +1748,7 @@ final class ApiFixtures
|
|||||||
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
||||||
|
$this->deleteWhereIfPossible('limited_backoffice_employees', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
|
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
|
||||||
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
|
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
|
||||||
$this->deleteWhereIfPossible('customer_fixed_pricing_versions', ['customer_number' => $customerNumber]);
|
$this->deleteWhereIfPossible('customer_fixed_pricing_versions', ['customer_number' => $customerNumber]);
|
||||||
|
|||||||
@@ -396,10 +396,8 @@ CREATE TABLE IF NOT EXISTS `products` (
|
|||||||
`max_quantity_per_order` INT NULL,
|
`max_quantity_per_order` INT NULL,
|
||||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`deleted_at` DATETIME NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_products_category` (`category`),
|
KEY `idx_products_category` (`category`)
|
||||||
KEY `idx_products_deleted_at` (`deleted_at`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'department_categories' => <<<'SQL'
|
'department_categories' => <<<'SQL'
|
||||||
@@ -426,6 +424,25 @@ CREATE TABLE IF NOT EXISTS `product_department_prices` (
|
|||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)
|
UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'limited_backoffice_employees' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`managed_group_id` INT NOT NULL,
|
||||||
|
`role_key` VARCHAR(64) NOT NULL,
|
||||||
|
`department_ids` LONGTEXT NOT NULL,
|
||||||
|
`created_by_user_id` INT NOT NULL,
|
||||||
|
`updated_by_user_id` INT NULL,
|
||||||
|
`deactivated_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||||
|
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||||
|
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||||
|
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'collected_order_invoices' => <<<'SQL'
|
'collected_order_invoices' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `collected_order_invoices` (
|
CREATE TABLE IF NOT EXISTS `collected_order_invoices` (
|
||||||
@@ -649,6 +666,26 @@ CREATE TABLE IF NOT EXISTS `tokens` (
|
|||||||
KEY `idx_tokens_user_id` (`user_id`),
|
KEY `idx_tokens_user_id` (`user_id`),
|
||||||
KEY `idx_tokens_type` (`type`)
|
KEY `idx_tokens_type` (`type`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'passkeys' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `passkeys` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`is_subuser` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`credential_id` VARCHAR(4096) NOT NULL,
|
||||||
|
`public_key` TEXT NOT NULL,
|
||||||
|
`algorithm` VARCHAR(32) NOT NULL,
|
||||||
|
`transports` JSON NULL,
|
||||||
|
`sign_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`backup_state` JSON NULL,
|
||||||
|
`name` VARCHAR(255) NULL,
|
||||||
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_passkeys_user` (`user_id`, `is_subuser`),
|
||||||
|
KEY `idx_passkeys_deleted_at` (`deleted_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'customer_attributes' => <<<'SQL'
|
'customer_attributes' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `customer_attributes` (
|
CREATE TABLE IF NOT EXISTS `customer_attributes` (
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('routes collected invoice draft line uploads through the multi-order batch endpoint', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
$start = strpos($content, 'public function addInvoicesToDraft');
|
||||||
|
$end = strpos($content, 'public function getLastEconomicTransferMetrics');
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
expect($end)->not->toBeFalse();
|
||||||
|
expect($end)->toBeGreaterThan($start);
|
||||||
|
|
||||||
|
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||||
|
expect($methodBlock)->toContain('$order_objects = [];')
|
||||||
|
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);')
|
||||||
|
->and($methodBlock)->toContain('...$metrics')
|
||||||
|
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
$singleStart = strpos($content, 'public function add_order');
|
||||||
|
$singleEnd = strpos($content, 'public function add_orders');
|
||||||
|
expect($singleStart)->not->toBeFalse();
|
||||||
|
expect($singleEnd)->not->toBeFalse();
|
||||||
|
expect($singleEnd)->toBeGreaterThan($singleStart);
|
||||||
|
|
||||||
|
$singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart);
|
||||||
|
expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);');
|
||||||
|
|
||||||
|
$batchBlock = substr($content, (int)$singleEnd);
|
||||||
|
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
|
||||||
|
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();')
|
||||||
|
->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;");
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use helpers\economic_invoice_draft;
|
||||||
|
|
||||||
|
if (!class_exists('EconomicInvoiceDraftBatchingProbe')) {
|
||||||
|
class EconomicInvoiceDraftBatchingProbe extends economic_invoice_draft
|
||||||
|
{
|
||||||
|
public array $sentBatches = [];
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
|
{
|
||||||
|
$this->sentBatches[] = $draft_lines;
|
||||||
|
return (object)['lines' => $draft_lines];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) {
|
||||||
|
class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe
|
||||||
|
{
|
||||||
|
public int $failOnBatch = 1;
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
|
{
|
||||||
|
if (count($this->sentBatches) + 1 === $this->failOnBatch) {
|
||||||
|
throw new RuntimeException('Simulated e-conomic line batch failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::sendDraftLines($draft_lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not call e-conomic when flushing an empty draft line buffer', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches();
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 0,
|
||||||
|
'batch_count' => 0,
|
||||||
|
'batch_sizes' => [],
|
||||||
|
])->and($draft->sentBatches)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes a small draft line buffer in one request and clears pending lines', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
$draft->addTextLine('line-0');
|
||||||
|
$draft->addTextLine('line-1');
|
||||||
|
$draft->addTextLine('line-2');
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches(500);
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 3,
|
||||||
|
'batch_count' => 1,
|
||||||
|
'batch_sizes' => [3],
|
||||||
|
])->and($draft->sentBatches)->toHaveCount(1)
|
||||||
|
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||||
|
->and($draft->sentBatches[0][2]['description'])->toBe('line-2')
|
||||||
|
->and($draft->pendingLineCount())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks large draft line buffers while preserving line order', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
for ($i = 0; $i < 1201; $i++) {
|
||||||
|
$draft->addTextLine('line-' . $i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches(500);
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 1201,
|
||||||
|
'batch_count' => 3,
|
||||||
|
'batch_sizes' => [500, 500, 201],
|
||||||
|
])->and($draft->sentBatches)->toHaveCount(3)
|
||||||
|
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||||
|
->and($draft->sentBatches[1][0]['description'])->toBe('line-500')
|
||||||
|
->and($draft->sentBatches[2][200]['description'])->toBe('line-1200')
|
||||||
|
->and($draft->pendingLineCount())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubbles line batch failures and keeps pending lines available', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true);
|
||||||
|
$draft->addTextLine('line-0');
|
||||||
|
|
||||||
|
expect(fn () => $draft->flushLinesInBatches(500))
|
||||||
|
->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure');
|
||||||
|
|
||||||
|
expect($draft->sentBatches)->toBe([])
|
||||||
|
->and($draft->pendingLineCount())->toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\customer_order_product_policy;
|
||||||
|
|
||||||
|
it('recognizes tankcleaning products by category and legacy names', function (): void {
|
||||||
|
expect(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Other',
|
||||||
|
]))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::isTankCleaningProductRow([
|
||||||
|
'product_category' => 3,
|
||||||
|
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||||
|
'category_name' => 'Tankrens',
|
||||||
|
]))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
|
||||||
|
$washProduct = [
|
||||||
|
'product_category' => 4,
|
||||||
|
'product_name' => 'Forvogn',
|
||||||
|
'category_name' => 'Udvendig',
|
||||||
|
];
|
||||||
|
$tankCleaningProduct = [
|
||||||
|
'product_category' => 5,
|
||||||
|
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||||
|
'category_name' => 'Tank cleaning',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
|
||||||
|
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use classes\router;
|
||||||
|
|
||||||
|
it('matches dynamic route parameter names containing underscores', function (): void {
|
||||||
|
$_SERVER['REQUEST_URI'] = '/modules/self-serve/lane/hardware/batch/916cdba1981e52f3dc728ea06fc39cf0';
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
|
||||||
|
$router = new router();
|
||||||
|
$matcher = new ReflectionMethod(router::class, 'doesRouteMatchCurrent');
|
||||||
|
|
||||||
|
expect($matcher->invoke(
|
||||||
|
$router,
|
||||||
|
'/modules/self-serve/lane/hardware/batch/{batch_id}'
|
||||||
|
))->toBeTrue();
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('services broker transport before replaying queued outbox items', function (): void {
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
$runOffset = strpos($agentSource, 'public function run(): void');
|
||||||
|
expect($runOffset)->not->toBeFalse();
|
||||||
|
|
||||||
|
$loopSource = substr($agentSource, (int)$runOffset, 1800);
|
||||||
|
$configureOffset = strpos($loopSource, '$this->configureBrokerClient();');
|
||||||
|
$pumpOffset = strpos($loopSource, '$this->pumpBrokerTransport();');
|
||||||
|
$flushOffset = strpos($loopSource, '$this->flushOutbox();');
|
||||||
|
|
||||||
|
expect($configureOffset)->not->toBeFalse()
|
||||||
|
->and($pumpOffset)->not->toBeFalse()
|
||||||
|
->and($flushOffset)->not->toBeFalse()
|
||||||
|
->and((int)$configureOffset)->toBeLessThan((int)$pumpOffset)
|
||||||
|
->and((int)$pumpOffset)->toBeLessThan((int)$flushOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds stale outbox replay so it cannot monopolize the gateway loop', function (): void {
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
|
||||||
|
expect($agentSource)->toContain('private const OUTBOX_REPLAY_BATCH_LIMIT = 3;')
|
||||||
|
->and($agentSource)->toContain('private const OUTBOX_REPLAY_TIMEOUT_SECONDS = 3;')
|
||||||
|
->and($agentSource)->toContain('private const OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS = 10;')
|
||||||
|
->and($agentSource)->toContain('private const OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS = 15;')
|
||||||
|
->and($agentSource)->toContain('private int $lastOutboxFailureAt = 0;')
|
||||||
|
->and($agentSource)->toContain('$this->stateStore->queuedItems(self::OUTBOX_REPLAY_BATCH_LIMIT)')
|
||||||
|
->and($agentSource)->toContain('private function shouldSkipOutboxReplay(): bool')
|
||||||
|
->and($agentSource)->toContain('$this->lastOutboxFailureAt = time();')
|
||||||
|
->and($agentSource)->toContain('? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS')
|
||||||
|
->and($agentSource)->toContain(': self::OUTBOX_REPLAY_TIMEOUT_SECONDS;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps draining API command jobs while broker transport is connected', function (): void {
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
$runOffset = strpos($agentSource, 'public function run(): void');
|
||||||
|
expect($runOffset)->not->toBeFalse();
|
||||||
|
|
||||||
|
$loopSource = substr($agentSource, (int)$runOffset, 1900);
|
||||||
|
|
||||||
|
expect($agentSource)->toContain('private const BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS = 1;')
|
||||||
|
->and($agentSource)->toContain('private int $lastBrokerConnectedCommandPollAt = 0;')
|
||||||
|
->and($agentSource)->toContain('private function shouldPollApiCommandQueue(bool $brokerConnected): bool')
|
||||||
|
->and($agentSource)->toContain('private function processCommandQueue(?int $waitSecondsOverride = null): void')
|
||||||
|
->and($loopSource)->toContain('$brokerConnected = $this->isBrokerConnected();')
|
||||||
|
->and($loopSource)->toContain('if (!$brokerConnected) {')
|
||||||
|
->and($loopSource)->toContain('$processedManagementOperation = $this->processManagementOperation();')
|
||||||
|
->and($loopSource)->toContain('$this->processCommandQueue($brokerConnected ? 0 : null);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a short control-plane timeout for self-serve machine signals before queueing', function (): void {
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
|
||||||
|
expect($agentSource)->toContain('private const MACHINE_SIGNAL_TIMEOUT_SECONDS = 3;')
|
||||||
|
->and($agentSource)->toContain('private function sendControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): bool')
|
||||||
|
->and($agentSource)->toContain("'machine_signal',\n self::MACHINE_SIGNAL_TIMEOUT_SECONDS");
|
||||||
|
});
|
||||||
@@ -65,12 +65,13 @@ function remove_edge_gateway_temp_path(string $path): void
|
|||||||
|
|
||||||
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
|
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
|
||||||
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
|
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
|
||||||
|
$normalizedPath = str_replace('\\', '/', $path);
|
||||||
|
|
||||||
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
|
expect($normalizedPath)->toEndWith('/resources/edge-gateway-agent/agent.php');
|
||||||
expect(is_file($path))->toBeTrue();
|
expect(is_file($path))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
|
it('prioritizes router-owned artifacts before implicit generated build output', function (): void {
|
||||||
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
|
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
|
||||||
$candidatePaths = array_map(
|
$candidatePaths = array_map(
|
||||||
static fn(string $path): string => str_replace('\\', '/', $path),
|
static fn(string $path): string => str_replace('\\', '/', $path),
|
||||||
@@ -82,16 +83,75 @@ it('prioritizes router resources before mounted and baked-in artifact directorie
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(array_slice($candidatePaths, 0, 3))->toBe([
|
expect(array_slice($candidatePaths, 0, 5))->toBe([
|
||||||
'/var/www/html/resources/edge-gateway-agent/agent.php',
|
'/var/www/html/resources/edge-gateway-agent/agent.php',
|
||||||
'/services/edge-agent/php-agent/agent.php',
|
'/edge-agent/build/install/agent.php',
|
||||||
'/opt/truckwash-edge-agent-artifacts/agent.php',
|
'/services/edge-agent/build/install/agent.php',
|
||||||
|
'/var/edge-agent/build/install/agent.php',
|
||||||
|
'/var/www/edge-agent/build/install/agent.php',
|
||||||
]);
|
]);
|
||||||
|
expect($candidatePaths)->toContain('/services/edge-agent/php-agent/agent.php');
|
||||||
|
expect($candidatePaths)->toContain('/opt/truckwash-edge-agent-artifacts/agent.php');
|
||||||
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
|
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
|
||||||
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
|
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prioritizes EDGE_AGENT_ARTIFACT_DIR before generated edge-agent build output', function (): void {
|
||||||
|
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => '/tmp/custom-edge-artifacts'], function (): void {
|
||||||
|
$candidatePaths = array_map(
|
||||||
|
static fn(string $path): string => str_replace('\\', '/', $path),
|
||||||
|
edge_gateway_agent_artifact_locator::candidatePaths(
|
||||||
|
'agent.php',
|
||||||
|
'/var/www/html',
|
||||||
|
'/services/edge-agent/php-agent',
|
||||||
|
'/opt/truckwash-edge-agent-artifacts'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(array_slice($candidatePaths, 0, 4))->toBe([
|
||||||
|
'/tmp/custom-edge-artifacts/agent.php',
|
||||||
|
'/var/www/html/resources/edge-gateway-agent/agent.php',
|
||||||
|
'/edge-agent/build/install/agent.php',
|
||||||
|
'/services/edge-agent/build/install/agent.php',
|
||||||
|
]);
|
||||||
|
expect($candidatePaths)->toContain('/var/edge-agent/build/install/agent.php');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a manifest with hashes for the resolved artifacts', function (): void {
|
||||||
|
$service = new EdgeGatewayInstallServiceHarness();
|
||||||
|
$manifest = json_decode($service->readArtifact('manifest.json'), true);
|
||||||
|
|
||||||
|
expect($manifest)->toBeArray()
|
||||||
|
->and($manifest['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
|
||||||
|
|
||||||
|
$artifacts = [];
|
||||||
|
foreach ((array)($manifest['artifacts'] ?? []) as $artifact) {
|
||||||
|
if (is_array($artifact)) {
|
||||||
|
$artifacts[(string)($artifact['name'] ?? '')] = $artifact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'agent.php',
|
||||||
|
'lan-worker.php',
|
||||||
|
'auto-updater.php',
|
||||||
|
'docker-compose.gateway.yml',
|
||||||
|
'Dockerfile.edge-agent',
|
||||||
|
'Dockerfile.lan-worker',
|
||||||
|
'Dockerfile.auto-updater',
|
||||||
|
'gateway-launcher.sh',
|
||||||
|
'truckwash-edge-gateway-stack.service',
|
||||||
|
'truckwash-edge-agent.service',
|
||||||
|
] as $fileName) {
|
||||||
|
$path = edge_gateway_agent_artifact_locator::resolve($fileName, app_path());
|
||||||
|
expect($artifacts)->toHaveKey($fileName)
|
||||||
|
->and($artifacts[$fileName]['sha256'] ?? null)->toBe(hash_file('sha256', $path))
|
||||||
|
->and($artifacts[$fileName]['bytes'] ?? null)->toBe(filesize($path));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reads install artifacts through the shared locator', function (): void {
|
it('reads install artifacts through the shared locator', function (): void {
|
||||||
$service = new EdgeGatewayInstallServiceHarness();
|
$service = new EdgeGatewayInstallServiceHarness();
|
||||||
$contents = $service->readArtifact('truckwash-edge-gateway-stack.service');
|
$contents = $service->readArtifact('truckwash-edge-gateway-stack.service');
|
||||||
@@ -99,6 +159,27 @@ it('reads install artifacts through the shared locator', function (): void {
|
|||||||
expect($contents)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
expect($contents)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes install artifact line endings for shell-safe streaming', function (): void {
|
||||||
|
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-crlf-' . bin2hex(random_bytes(6));
|
||||||
|
$artifactPath = $tempRoot . DIRECTORY_SEPARATOR . 'gateway-launcher.sh';
|
||||||
|
|
||||||
|
@mkdir($tempRoot, 0777, true);
|
||||||
|
file_put_contents($artifactPath, "#!/usr/bin/env bash\r\nset -Eeuo pipefail\r\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => $tempRoot], function (): void {
|
||||||
|
$service = new EdgeGatewayInstallServiceHarness();
|
||||||
|
$contents = $service->readArtifact('gateway-launcher.sh');
|
||||||
|
|
||||||
|
expect($contents)->toBe("#!/usr/bin/env bash\nset -Eeuo pipefail\n");
|
||||||
|
expect($contents)->not->toContain("\r");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
remove_edge_gateway_temp_path($artifactPath);
|
||||||
|
remove_edge_gateway_temp_path($tempRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('builds update payloads with checksums from resolved artifact paths', function (): void {
|
it('builds update payloads with checksums from resolved artifact paths', function (): void {
|
||||||
$manager = new EdgeGatewayManagerArtifactHarness();
|
$manager = new EdgeGatewayManagerArtifactHarness();
|
||||||
$payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable');
|
$payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable');
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('wires self-serve machine signals through the broker control plane', function (): void {
|
||||||
|
$routeSource = (string)file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
$machineSignalSource = (string)file_get_contents(app_path('modules/selfserve/classes/selfserve_machine_signal.php'));
|
||||||
|
|
||||||
|
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/selfserve/machine-signal'")
|
||||||
|
->and($routeSource)->toContain('private function handleBrokerSelfserveMachineSignal(): void')
|
||||||
|
->and($routeSource)->toContain('recordBrokerEdgeGatewaySignal($gatewayId, $payload)')
|
||||||
|
->and($agentSource)->toContain("'type' => 'MACHINE_SIGNAL'")
|
||||||
|
->and($agentSource)->toContain('#/edge-agent/gateways/\d+/selfserve/machine-signal$#')
|
||||||
|
->and($machineSignalSource)->toContain('public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array')
|
||||||
|
->and($machineSignalSource)->toContain('private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array');
|
||||||
|
});
|
||||||
@@ -16,7 +16,11 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
|||||||
expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnly');
|
expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnly');
|
||||||
expect($managerSource)->toContain('public function dispatchRelaySwitchWithTimer');
|
expect($managerSource)->toContain('public function dispatchRelaySwitchWithTimer');
|
||||||
expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnlyWithTimer');
|
expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnlyWithTimer');
|
||||||
|
expect($managerSource)->toContain('public function queueRelayStatusBatch');
|
||||||
|
expect($managerSource)->toContain('public function queueRelaySwitchBatch');
|
||||||
|
expect($managerSource)->toContain('public function relayBatchStatus');
|
||||||
expect($managerSource)->toContain('private function createCommandJob');
|
expect($managerSource)->toContain('private function createCommandJob');
|
||||||
|
expect($managerSource)->toContain('private function queueRelayBatch');
|
||||||
expect($managerSource)->toContain("'GET_RELAY_STATUS'");
|
expect($managerSource)->toContain("'GET_RELAY_STATUS'");
|
||||||
expect($managerSource)->toContain("'SET_RELAY_STATE'");
|
expect($managerSource)->toContain("'SET_RELAY_STATE'");
|
||||||
expect($managerSource)->toContain("'relayId' => \$logicalRelayId");
|
expect($managerSource)->toContain("'relayId' => \$logicalRelayId");
|
||||||
@@ -34,6 +38,26 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
|||||||
expect($managerSource)->toContain("'Edge gateway command timed out', null, 'TIMED_OUT'");
|
expect($managerSource)->toContain("'Edge gateway command timed out', null, 'TIMED_OUT'");
|
||||||
expect($managerSource)->toContain('private function normalizeCommandFailureStatus');
|
expect($managerSource)->toContain('private function normalizeCommandFailureStatus');
|
||||||
expect($managerSource)->toContain('private function isCommandTimeoutError');
|
expect($managerSource)->toContain('private function isCommandTimeoutError');
|
||||||
|
expect($managerSource)->toContain("'batch_id' => \$batchId");
|
||||||
|
expect($managerSource)->toContain("'batch_dedupe_key' => \$batchDedupeKey");
|
||||||
|
expect($managerSource)->toContain("'idempotency_key' => \$batchId . ':' . (string)\$command['target']");
|
||||||
|
expect($managerSource)->toContain("'no_auto_retry' => \$isGatePulse");
|
||||||
|
expect($managerSource)->toContain('private function commandDisallowsAutomaticRetry');
|
||||||
|
expect($managerSource)->toContain('private function requeueCommandForApiPolling');
|
||||||
|
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
|
||||||
|
expect($managerSource)->toContain("'fallback_reason' => 'broker_dispatch_failed'");
|
||||||
|
expect($managerSource)->toContain("\$this->requeueCommandForApiPolling(\$job, \$exception->getMessage());");
|
||||||
|
expect($managerSource)->toContain('self::COMMAND_POLL_TIMEOUT_SECONDS + 5');
|
||||||
|
expect($managerSource)->toContain('return $this->waitForCommandResult((int)$job->id, $waitTimeoutSeconds);');
|
||||||
|
expect($managerSource)->toContain('private function buildRelayBatchDedupeKey');
|
||||||
|
expect($managerSource)->toContain('private function findRecentRelayBatchByDedupeKey');
|
||||||
|
expect($managerSource)->toContain('private function enforceRelayCommandRateLimit');
|
||||||
|
expect($managerSource)->toContain('RELAY_GATE_RATE_LIMIT_PER_MINUTE');
|
||||||
|
expect($managerSource)->toContain("JSON_EXTRACT(delivery_json, \\'$.no_auto_retry\\')");
|
||||||
|
expect($managerSource)->toContain('not retrying non-idempotent gate pulse');
|
||||||
|
expect($managerSource)->toContain('UNKNOWN_OUTCOME');
|
||||||
|
expect($managerSource)->toContain('recent_unknown_gate_outcomes');
|
||||||
|
expect($managerSource)->toContain('recent_relay_failure_rate');
|
||||||
expect($managerSource)->toContain('local_transport_override');
|
expect($managerSource)->toContain('local_transport_override');
|
||||||
expect($managerSource)->toContain('private function resolveRelayBindingLocalIp');
|
expect($managerSource)->toContain('private function resolveRelayBindingLocalIp');
|
||||||
expect($managerSource)->toContain('private function findShellyCloudRelayLocalIp');
|
expect($managerSource)->toContain('private function findShellyCloudRelayLocalIp');
|
||||||
@@ -58,6 +82,28 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
|||||||
expect($managerSource)->toContain("command_type");
|
expect($managerSource)->toContain("command_type");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves relay bindings without requiring a primary department gateway first', function (): void {
|
||||||
|
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
|
||||||
|
expect($managerSource)->not->toBeFalse();
|
||||||
|
preg_match(
|
||||||
|
'/public function resolveRelayBinding\(int \$departmentId, string \$logicalRelayId\): array\s*\{(?P<body>.*?)\n \}\n\n \/\*\*/s',
|
||||||
|
(string)$managerSource,
|
||||||
|
$matches
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($matches)->toHaveKey('body');
|
||||||
|
$body = (string)$matches['body'];
|
||||||
|
|
||||||
|
expect($body)->toContain("'department_id' => \$departmentId")
|
||||||
|
->and($body)->toContain("'relay_id' => \$logicalRelayId")
|
||||||
|
->and($body)->not->toContain('getPrimaryGatewayForDepartment')
|
||||||
|
->and($body)->toContain('(new edge_gateways_o())->select((int)$row[\'gateway_id\'])')
|
||||||
|
->and($body)->toContain('No active edge gateway found for relay')
|
||||||
|
->and($body)->toContain('statusPriority')
|
||||||
|
->and($body)->toContain('heartbeatTimestamp');
|
||||||
|
});
|
||||||
|
|
||||||
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
|
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
|
||||||
$reflection = new ReflectionClass(edge_gateway_manager::class);
|
$reflection = new ReflectionClass(edge_gateway_manager::class);
|
||||||
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
|
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
|
||||||
@@ -71,6 +117,9 @@ it('loads relay command helpers on the manager and gateway operations on the ded
|
|||||||
expect($reflection->hasMethod('dispatchRelaySwitchLocalOnly'))->toBeTrue();
|
expect($reflection->hasMethod('dispatchRelaySwitchLocalOnly'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('dispatchRelaySwitchWithTimer'))->toBeTrue();
|
expect($reflection->hasMethod('dispatchRelaySwitchWithTimer'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('dispatchRelaySwitchLocalOnlyWithTimer'))->toBeTrue();
|
expect($reflection->hasMethod('dispatchRelaySwitchLocalOnlyWithTimer'))->toBeTrue();
|
||||||
|
expect($reflection->hasMethod('queueRelayStatusBatch'))->toBeTrue();
|
||||||
|
expect($reflection->hasMethod('queueRelaySwitchBatch'))->toBeTrue();
|
||||||
|
expect($reflection->hasMethod('relayBatchStatus'))->toBeTrue();
|
||||||
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
|
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
|
||||||
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
|
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
|
||||||
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
|
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
|||||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||||
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||||
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
|
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
|
||||||
|
expect($script)->toContain('fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json" "$INSTALL_DIR/manifest.json"');
|
||||||
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||||
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
||||||
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||||
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||||
|
expect($script)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
|
||||||
|
expect($script)->toContain('verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
|
||||||
expect($script)->toContain('report_install_status() {');
|
expect($script)->toContain('report_install_status() {');
|
||||||
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
|
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
|
||||||
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
|
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
|
||||||
@@ -87,14 +90,18 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
|||||||
expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}');
|
expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}');
|
||||||
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
||||||
expect($script)->toContain('Response body preview (first 400 bytes):');
|
expect($script)->toContain('Response body preview (first 400 bytes):');
|
||||||
|
expect($script)->toContain('begin_install_phase "REMOVE_EXISTING_INSTALL" "Removing existing edge gateway installation"');
|
||||||
|
expect($script)->toContain('run_step "Removing existing edge gateway installation" cleanup_existing_installation');
|
||||||
expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||||
expect($script)->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
expect($script)->not->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||||
|
expect($script)->not->toContain('Gateway reconnected using preserved credentials.');
|
||||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||||
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
|
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
|
||||||
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
|
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
|
||||||
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
|
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
|
||||||
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
||||||
|
expect($script)->toContain('"installedVersion":"compose-php-agent-v3"');
|
||||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
|
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
|
||||||
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function edge_gateway_relay_timer_free_port(): int
|
||||||
|
{
|
||||||
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||||||
|
if ($socket === false) {
|
||||||
|
throw new RuntimeException('Unable to allocate test port: ' . $errstr);
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = (string)stream_socket_get_name($socket, false);
|
||||||
|
fclose($socket);
|
||||||
|
|
||||||
|
return (int)substr(strrchr($address, ':'), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{process:resource,pipes:array<int,resource>}
|
||||||
|
*/
|
||||||
|
function edge_gateway_relay_timer_start_php_server(int $port, string $router, array $environment = []): array
|
||||||
|
{
|
||||||
|
$command = escapeshellarg(PHP_BINARY) . ' -S 127.0.0.1:' . $port . ' ' . escapeshellarg($router);
|
||||||
|
$descriptors = [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
];
|
||||||
|
$processEnvironment = [];
|
||||||
|
foreach (array_merge($_ENV, $_SERVER, $environment) as $key => $value) {
|
||||||
|
if (is_scalar($value) || $value === null) {
|
||||||
|
$processEnvironment[(string)$key] = (string)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = proc_open(
|
||||||
|
$command,
|
||||||
|
$descriptors,
|
||||||
|
$pipes,
|
||||||
|
app_path(),
|
||||||
|
$processEnvironment
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
throw new RuntimeException('Unable to start PHP test server');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
stream_set_blocking($pipe, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = microtime(true) + 5.0;
|
||||||
|
do {
|
||||||
|
set_error_handler(static fn(): bool => true);
|
||||||
|
$connection = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
|
||||||
|
restore_error_handler();
|
||||||
|
if (is_resource($connection)) {
|
||||||
|
fclose($connection);
|
||||||
|
return ['process' => $process, 'pipes' => $pipes];
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50000);
|
||||||
|
} while (microtime(true) < $deadline);
|
||||||
|
|
||||||
|
edge_gateway_relay_timer_stop_php_server(['process' => $process, 'pipes' => $pipes]);
|
||||||
|
throw new RuntimeException('Timed out waiting for PHP test server on port ' . $port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{process:resource,pipes:array<int,resource>}|null $server
|
||||||
|
*/
|
||||||
|
function edge_gateway_relay_timer_stop_php_server(?array $server): void
|
||||||
|
{
|
||||||
|
if ($server === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($server['pipes'] as $pipe) {
|
||||||
|
if (is_resource($pipe)) {
|
||||||
|
fclose($pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_resource($server['process'])) {
|
||||||
|
proc_terminate($server['process']);
|
||||||
|
proc_close($server['process']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status:int,body:array<string,mixed>,raw:string}
|
||||||
|
*/
|
||||||
|
function edge_gateway_relay_timer_post_json(int $port, string $path, array $payload): array
|
||||||
|
{
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => implode("\r\n", [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-TruckWash-Worker-Token: worker-token',
|
||||||
|
]) . "\r\n",
|
||||||
|
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES),
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'timeout' => 5,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$raw = file_get_contents('http://127.0.0.1:' . $port . $path, false, $context);
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new RuntimeException('Worker request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = 0;
|
||||||
|
foreach ($http_response_header ?? [] as $header) {
|
||||||
|
if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
|
||||||
|
$status = (int)$matches[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Worker returned invalid JSON: ' . $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => $status, 'body' => $decoded, 'raw' => $raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge_gateway_relay_timer_fake_shelly_router(string $directory): string
|
||||||
|
{
|
||||||
|
$router = $directory . DIRECTORY_SEPARATOR . 'fake-shelly.php';
|
||||||
|
file_put_contents($router, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$logPath = (string)getenv('SHELLY_REQUEST_LOG');
|
||||||
|
if ($logPath !== '') {
|
||||||
|
file_put_contents($logPath, (string)($_SERVER['REQUEST_URI'] ?? '/') . PHP_EOL, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
||||||
|
if (str_contains($path, '/rpc/Switch.Set') || str_contains($path, '/rpc/Switch.GetStatus')) {
|
||||||
|
echo json_encode(['output' => true], JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#/relay/\d+#', $path) === 1) {
|
||||||
|
echo json_encode(['ison' => true], JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true], JSON_UNESCAPED_SLASHES);
|
||||||
|
PHP);
|
||||||
|
|
||||||
|
return $router;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes direct relay switch timers through the LAN worker and prefers legacy timers for Gen1 relays', function (): void {
|
||||||
|
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-timer-' . bin2hex(random_bytes(6));
|
||||||
|
mkdir($tempDir, 0777, true);
|
||||||
|
$logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log';
|
||||||
|
$fakeServer = null;
|
||||||
|
$workerServer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fakePort = edge_gateway_relay_timer_free_port();
|
||||||
|
$workerPort = edge_gateway_relay_timer_free_port();
|
||||||
|
$fakeServer = edge_gateway_relay_timer_start_php_server(
|
||||||
|
$fakePort,
|
||||||
|
edge_gateway_relay_timer_fake_shelly_router($tempDir),
|
||||||
|
['SHELLY_REQUEST_LOG' => $logPath]
|
||||||
|
);
|
||||||
|
$workerServer = edge_gateway_relay_timer_start_php_server(
|
||||||
|
$workerPort,
|
||||||
|
app_path('resources/edge-gateway-agent/lan-worker.php'),
|
||||||
|
['TRUCKWASH_WORKER_TOKEN' => 'worker-token']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = edge_gateway_relay_timer_post_json($workerPort, '/relay/switch', [
|
||||||
|
'local_ip' => '127.0.0.1:' . $fakePort,
|
||||||
|
'channel' => 0,
|
||||||
|
'on' => true,
|
||||||
|
'timer' => 3,
|
||||||
|
'device_generation' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
|
expect($response['status'])->toBe(200)
|
||||||
|
->and($response['body']['on'])->toBeTrue()
|
||||||
|
->and($requests[0] ?? null)->toBe('/relay/0?turn=on&timer=3');
|
||||||
|
} finally {
|
||||||
|
edge_gateway_relay_timer_stop_php_server($workerServer);
|
||||||
|
edge_gateway_relay_timer_stop_php_server($fakeServer);
|
||||||
|
array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []);
|
||||||
|
@rmdir($tempDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps batch relay switch timers and sends Gen3 relays through Shelly RPC first', function (): void {
|
||||||
|
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-batch-' . bin2hex(random_bytes(6));
|
||||||
|
mkdir($tempDir, 0777, true);
|
||||||
|
$logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log';
|
||||||
|
$fakeServer = null;
|
||||||
|
$workerServer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fakePort = edge_gateway_relay_timer_free_port();
|
||||||
|
$workerPort = edge_gateway_relay_timer_free_port();
|
||||||
|
$fakeServer = edge_gateway_relay_timer_start_php_server(
|
||||||
|
$fakePort,
|
||||||
|
edge_gateway_relay_timer_fake_shelly_router($tempDir),
|
||||||
|
['SHELLY_REQUEST_LOG' => $logPath]
|
||||||
|
);
|
||||||
|
$workerServer = edge_gateway_relay_timer_start_php_server(
|
||||||
|
$workerPort,
|
||||||
|
app_path('resources/edge-gateway-agent/lan-worker.php'),
|
||||||
|
['TRUCKWASH_WORKER_TOKEN' => 'worker-token']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = edge_gateway_relay_timer_post_json($workerPort, '/relay/batch-switch', [
|
||||||
|
'batch_id' => 'batch-1',
|
||||||
|
'commands' => [[
|
||||||
|
'target' => 'EXIT',
|
||||||
|
'relay_id' => 'relay-out',
|
||||||
|
'local_ip' => '127.0.0.1:' . $fakePort,
|
||||||
|
'channel' => 0,
|
||||||
|
'on' => true,
|
||||||
|
'toggle_after' => 999,
|
||||||
|
'device_model' => 'S3SW-001X8EU',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
|
expect($response['status'])->toBe(200)
|
||||||
|
->and($response['body']['results'][0]['ok'])->toBeTrue()
|
||||||
|
->and($response['body']['results'][0]['relay_id'])->toBe('relay-out')
|
||||||
|
->and($requests[0] ?? null)->toBe('/rpc/Switch.Set?id=0&on=true&toggle_after=5');
|
||||||
|
} finally {
|
||||||
|
edge_gateway_relay_timer_stop_php_server($workerServer);
|
||||||
|
edge_gateway_relay_timer_stop_php_server($fakeServer);
|
||||||
|
array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []);
|
||||||
|
@rmdir($tempDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps PHP edge-agent relay fallback propagation aligned with LAN worker timer handling', function (): void {
|
||||||
|
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||||
|
|
||||||
|
expect($agentSource)->toContain('private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;')
|
||||||
|
->and($agentSource)->toContain('$workerPayload[\'toggle_after\'] = $toggleAfter;')
|
||||||
|
->and($agentSource)->toContain('$workerPayload[\'device_generation\'] = $deviceGeneration;')
|
||||||
|
->and($agentSource)->toContain('private function resolveRelayToggleAfterSeconds(array $request): ?int')
|
||||||
|
->and($agentSource)->toContain('private function resolveShellyCommandGeneration(array $request): ?int')
|
||||||
|
->and($agentSource)->toContain('$deviceGeneration === 1')
|
||||||
|
->and($agentSource)->toContain('rawurlencode((string)$toggleAfter)')
|
||||||
|
->and($agentSource)->toContain("'&timer='");
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
|||||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||||
expect($route)->toContain("'/edge-agent/install-token/status'");
|
expect($route)->toContain("'/edge-agent/install-token/status'");
|
||||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||||
|
expect($route)->toContain("'/edge-agent/artifacts/manifest.json'");
|
||||||
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||||
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
||||||
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
|
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ it('defines the v2 edge gateway schema bootstrap tables', function (): void {
|
|||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
|
||||||
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
|
||||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
||||||
@@ -23,6 +24,8 @@ it('stores operation metadata and event timelines for management workflows', fun
|
|||||||
|
|
||||||
expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL');
|
expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL');
|
||||||
expect($bootstrapContent)->toContain('delivery_json JSON NULL');
|
expect($bootstrapContent)->toContain('delivery_json JSON NULL');
|
||||||
|
expect($bootstrapContent)->toContain('expected_state TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
expect($bootstrapContent)->toContain('UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role)');
|
||||||
expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'");
|
expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'");
|
||||||
expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL');
|
expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL');
|
||||||
expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'");
|
expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'");
|
||||||
@@ -39,6 +42,9 @@ it('stores operation metadata and event timelines for management workflows', fun
|
|||||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')");
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')");
|
||||||
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
|
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
|
||||||
expect($bootstrapContent)->toContain('SET type = operation_type');
|
expect($bootstrapContent)->toContain('SET type = operation_type');
|
||||||
|
expect($bootstrapContent)->toContain('private static function databaseConfigFromEnvironment(): ?array');
|
||||||
|
expect($bootstrapContent)->toContain("databaseEnvValue('HOST', \$target)");
|
||||||
|
expect($bootstrapContent)->toContain('private static function connectPdo(array $config): \PDO');
|
||||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
|
||||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
|
||||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')");
|
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
it('builds the installer around the compose stack artifacts and management polling config', function (): void {
|
it('builds the installer around the compose stack artifacts and management polling config', function (): void {
|
||||||
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||||
|
$installServiceSource = file_get_contents(app_path('classes/edge_gateway_install_service.php'));
|
||||||
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
|
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
|
||||||
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
|
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
|
||||||
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
|
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
|
||||||
@@ -23,13 +24,19 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($autoUpdaterSource)->not->toBeFalse();
|
expect($autoUpdaterSource)->not->toBeFalse();
|
||||||
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
|
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
|
||||||
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||||
|
expect($managerSource)->toContain("public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3'");
|
||||||
|
expect($managerSource)->toContain("'installedVersion' => self::DEFAULT_INSTALL_VERSION");
|
||||||
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
||||||
|
expect($managerSource)->toContain('fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
|
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
|
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
|
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
|
||||||
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||||
|
expect($managerSource)->toContain('verify_manifest_artifact()');
|
||||||
|
expect($managerSource)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
|
||||||
|
expect($managerSource)->toContain('run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
|
||||||
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
||||||
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
||||||
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
|
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
|
||||||
@@ -37,21 +44,50 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($managerSource)->toContain('apt-get install -y docker-compose-plugin');
|
expect($managerSource)->toContain('apt-get install -y docker-compose-plugin');
|
||||||
expect($managerSource)->toContain('apt-get install -y docker-compose');
|
expect($managerSource)->toContain('apt-get install -y docker-compose');
|
||||||
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
|
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
|
||||||
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
|
expect($managerSource)->toContain('begin_install_phase "REMOVE_EXISTING_INSTALL" "Removing existing edge gateway installation"');
|
||||||
|
expect($managerSource)->toContain('run_step "Removing existing edge gateway installation" cleanup_existing_installation');
|
||||||
|
expect($managerSource)->toContain('cleanup_existing_installation()');
|
||||||
|
expect($managerSource)->toContain('systemctl stop truckwash-edge-gateway-stack.service');
|
||||||
|
expect($managerSource)->toContain('systemctl stop truckwash-edge-agent.service');
|
||||||
|
expect($managerSource)->toContain('TRUCKWASH_INSTALL_DIR="$INSTALL_DIR" "$INSTALL_DIR/gateway-launcher.sh" down >/dev/null 2>&1 || true');
|
||||||
|
expect($managerSource)->toContain('docker rm -f');
|
||||||
|
expect($managerSource)->toContain('remove_edge_gateway_containers()');
|
||||||
|
expect($managerSource)->toContain('remove_docker_containers_matching_filter "name=${name}"');
|
||||||
|
expect($managerSource)->toContain('remove_docker_containers_matching_filter "label=com.docker.compose.project=${project_name}"');
|
||||||
|
expect($managerSource)->toContain('remove_docker_containers_matching_filter "label=com.docker.compose.project.working_dir=${INSTALL_DIR}"');
|
||||||
|
expect($managerSource)->toContain('truckwash-minio');
|
||||||
|
expect($managerSource)->toContain('rm -f "$STACK_SERVICE_PATH" "$LEGACY_SERVICE_PATH"');
|
||||||
|
expect($managerSource)->toContain('rm -rf "$INSTALL_DIR"');
|
||||||
|
expect($managerSource)->toContain('systemctl reset-failed truckwash-edge-agent.service >/dev/null 2>&1 || true');
|
||||||
|
expect($managerSource)->toContain('return 0');
|
||||||
expect($managerSource)->toContain('report_install_status() {');
|
expect($managerSource)->toContain('report_install_status() {');
|
||||||
expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"');
|
expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"');
|
||||||
expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"');
|
expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"');
|
||||||
expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
||||||
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
expect($managerSource)->toContain('print_collected_diagnostics()');
|
||||||
|
expect($managerSource)->toContain('log_error "Diagnostic: ${DIAGNOSTIC_NAMES[$index]}"');
|
||||||
|
expect($managerSource)->toContain("collect_install_diagnostics\n diagnostics_json=\"$(emit_diagnostic_json)\"");
|
||||||
|
expect($managerSource)->not->toContain('diagnostics_json="$(collect_install_diagnostics)"');
|
||||||
|
expect($managerSource)->toContain('print_collected_diagnostics');
|
||||||
|
expect($managerSource)->toContain('run_step "Writing fresh agent config" cp "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
||||||
|
expect($managerSource)->not->toContain('REUSE_EXISTING_CREDENTIALS');
|
||||||
|
expect($managerSource)->not->toContain('merge_agent_config()');
|
||||||
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
|
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
|
||||||
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
|
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
|
||||||
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
|
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
|
||||||
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
|
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
|
||||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
expect($managerSource)->not->toContain('run_step "Waiting for post-reinstall heartbeat"');
|
||||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
|
expect($managerSource)->not->toContain('Gateway reconnected using preserved credentials.');
|
||||||
|
expect($managerSource)->toContain('journalctl -a -u truckwash-edge-gateway-stack.service -n 80 --no-pager');
|
||||||
|
expect($managerSource)->toContain("docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'");
|
||||||
|
expect($managerSource)->toContain('logs --no-color --no-log-prefix --tail=120');
|
||||||
expect($managerSource)->not->toContain('agent.mjs');
|
expect($managerSource)->not->toContain('agent.mjs');
|
||||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||||
|
expect($installServiceSource)->toContain('normalizeLineEndings($this->manager()->buildInstallScript($plainToken))');
|
||||||
|
expect($installServiceSource)->toContain("'manifest.json' => 'application/json; charset=utf-8'");
|
||||||
|
expect($installServiceSource)->toContain("'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION");
|
||||||
|
expect($installServiceSource)->toContain('str_replace(["\r\n", "\r"], "\n", $contents)');
|
||||||
expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit');
|
expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit');
|
||||||
expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||||
expect($serviceSource)->toContain('ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile');
|
expect($serviceSource)->toContain('ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile');
|
||||||
@@ -67,8 +103,13 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($launcherSource)->toContain('ensure_stack_env');
|
expect($launcherSource)->toContain('ensure_stack_env');
|
||||||
expect($launcherSource)->toContain('REDIS_PASSWORD');
|
expect($launcherSource)->toContain('REDIS_PASSWORD');
|
||||||
expect($launcherSource)->toContain('chmod 0600 "$ENV_FILE"');
|
expect($launcherSource)->toContain('chmod 0600 "$ENV_FILE"');
|
||||||
|
expect($launcherSource)->toContain('staged_update_value()');
|
||||||
|
expect($launcherSource)->toContain('window="$(staged_update_value update_window \'\')"');
|
||||||
|
expect($launcherSource)->toContain('Staged update already applied; nothing to reconcile');
|
||||||
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
|
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
|
||||||
expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true');
|
expect($launcherSource)->toContain("docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'");
|
||||||
|
expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" ps -a --no-trunc || true');
|
||||||
|
expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" logs --no-color --no-log-prefix --tail=120 || true');
|
||||||
expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"');
|
expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"');
|
||||||
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"');
|
||||||
@@ -80,9 +121,9 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($normalizedComposeSource)->toContain("mariadb:\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[\\"started_at\\"]');
|
||||||
expect($composeSource)->toContain('<= 30');
|
expect($composeSource)->not->toContain('$$data[\\"last_successful_sync_at\\"]');
|
||||||
expect($composeSource)->toContain('<= 90');
|
expect($composeSource)->toContain('<= 120');
|
||||||
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
|
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
|
||||||
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
|
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
|
||||||
expect($composeSource)->toContain('./config.json:/config/config.json:ro');
|
expect($composeSource)->toContain('./config.json:/config/config.json:ro');
|
||||||
@@ -104,6 +145,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($composeSource)->toContain('container_name: truckwash-minio');
|
expect($composeSource)->toContain('container_name: truckwash-minio');
|
||||||
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
|
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
|
||||||
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
|
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
|
||||||
|
expect($agentSource)->toContain('compose-php-agent-v3');
|
||||||
expect($agentSource)->toContain('control-plane-status.json');
|
expect($agentSource)->toContain('control-plane-status.json');
|
||||||
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
|
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
|
||||||
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
|
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
|
||||||
@@ -112,25 +154,36 @@ it('builds the installer around the compose stack artifacts and management polli
|
|||||||
expect($agentSource)->toContain("'last_transport_failure_at'");
|
expect($agentSource)->toContain("'last_transport_failure_at'");
|
||||||
expect($agentSource)->toContain("'last_transport_error'");
|
expect($agentSource)->toContain("'last_transport_error'");
|
||||||
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
|
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
|
||||||
|
expect($agentSource)->toContain(
|
||||||
|
"private function reloadConfigFromDisk(): void\n" .
|
||||||
|
" {\n" .
|
||||||
|
" \$reloaded = AgentConfig::load(\$this->config->path);\n" .
|
||||||
|
" \$this->config = \$reloaded;\n" .
|
||||||
|
" \$this->workerHttp = new HttpJsonClient(\n" .
|
||||||
|
" (string)\$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),\n" .
|
||||||
|
" \$this->workerAuthorizationHeaders()\n" .
|
||||||
|
' );'
|
||||||
|
);
|
||||||
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||||
expect($edgeDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;');
|
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||||
expect($edgeDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;');
|
|
||||||
expect($edgeDockerfileSource)->toContain('extension_loaded($extension)');
|
expect($edgeDockerfileSource)->toContain('extension_loaded($extension)');
|
||||||
|
expect($edgeDockerfileSource)->toContain('"pdo_sqlite"');
|
||||||
expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
||||||
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
|
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
|
||||||
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||||
expect($workerDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;');
|
expect($workerDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||||
expect($workerDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;');
|
|
||||||
expect($workerDockerfileSource)->toContain('extension_loaded($extension)');
|
expect($workerDockerfileSource)->toContain('extension_loaded($extension)');
|
||||||
|
expect($workerDockerfileSource)->toContain('"pdo_sqlite"');
|
||||||
expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
||||||
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
|
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
|
||||||
expect($workerSource)->toContain('worker_require_authorization');
|
expect($workerSource)->toContain('worker_require_authorization');
|
||||||
expect($agentSource)->toContain('X-Truckwash-Worker-Token: ');
|
expect($agentSource)->toContain('X-Truckwash-Worker-Token: ');
|
||||||
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
|
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
|
||||||
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
|
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
|
||||||
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev;');
|
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;');
|
||||||
expect($autoUpdaterDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;');
|
expect($autoUpdaterDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||||
expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)');
|
expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)');
|
||||||
|
expect($autoUpdaterDockerfileSource)->toContain('"pdo_sqlite"');
|
||||||
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +208,8 @@ it('exposes update payload, credential rotation, cancel endpoints, and operation
|
|||||||
expect($agentSource)->toContain('private AgentShellBridge $shellBridge;');
|
expect($agentSource)->toContain('private AgentShellBridge $shellBridge;');
|
||||||
expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool');
|
expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool');
|
||||||
expect($agentSource)->toContain('private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool');
|
expect($agentSource)->toContain('private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool');
|
||||||
|
expect($agentSource)->toContain('private function normalizeBrokerUrlForComparison(?string $value): ?string');
|
||||||
|
expect($agentSource)->toContain('$currentNormalized = $this->normalizeBrokerUrlForComparison($current);');
|
||||||
expect($agentSource)->toContain('if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint))');
|
expect($agentSource)->toContain('if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint))');
|
||||||
expect($agentSource)->toContain('if ($brokerDispatch !== true || $this->shouldPersistControlPlaneEventOverHttp($endpoint))');
|
expect($agentSource)->toContain('if ($brokerDispatch !== true || $this->shouldPersistControlPlaneEventOverHttp($endpoint))');
|
||||||
expect($agentSource)->toContain('Broker dispatched \' . $type . \' but HTTP persistence failed on \' . $endpoint');
|
expect($agentSource)->toContain('Broker dispatched \' . $type . \' but HTTP persistence failed on \' . $endpoint');
|
||||||
@@ -172,7 +227,7 @@ it('exposes update payload, credential rotation, cancel endpoints, and operation
|
|||||||
expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool');
|
expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool');
|
||||||
expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';");
|
expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';");
|
||||||
expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';");
|
expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';");
|
||||||
expect($agentSource)->toContain("? self::OPERATION_COMPLETE_TIMEOUT_SECONDS");
|
expect($agentSource)->toContain("? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS");
|
||||||
expect($agentSource)->toContain("unset(\$state['completion']);");
|
expect($agentSource)->toContain("unset(\$state['completion']);");
|
||||||
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
|
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
|
||||||
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
|
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager
|
|||||||
public array $localOnlyStatusCalls = [];
|
public array $localOnlyStatusCalls = [];
|
||||||
/** @var array<int,array<string,mixed>> */
|
/** @var array<int,array<string,mixed>> */
|
||||||
public array $localOnlySwitchCalls = [];
|
public array $localOnlySwitchCalls = [];
|
||||||
|
/** @var array<int,array<string,mixed>> */
|
||||||
|
public array $batchStatusCalls = [];
|
||||||
|
/** @var array<int,array<string,mixed>> */
|
||||||
|
public array $batchSwitchCalls = [];
|
||||||
public ?Exception $statusException = null;
|
public ?Exception $statusException = null;
|
||||||
public ?Exception $switchException = null;
|
public ?Exception $switchException = null;
|
||||||
|
|
||||||
@@ -128,6 +132,44 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager
|
|||||||
'raw' => ['source' => 'local-switch'],
|
'raw' => ['source' => 'local-switch'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function queueRelayStatusBatch(
|
||||||
|
int $departmentId,
|
||||||
|
array $requests,
|
||||||
|
?int $userId = null,
|
||||||
|
bool $requireFastLocalPath = false
|
||||||
|
): array {
|
||||||
|
$this->batchStatusCalls[] = [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'requests' => $requests,
|
||||||
|
'local_only' => $requireFastLocalPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'batch_id' => 'batch-status',
|
||||||
|
'status' => 'PENDING',
|
||||||
|
'items' => $requests,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function queueRelaySwitchBatch(
|
||||||
|
int $departmentId,
|
||||||
|
array $requests,
|
||||||
|
?int $userId = null,
|
||||||
|
bool $requireFastLocalPath = false
|
||||||
|
): array {
|
||||||
|
$this->batchSwitchCalls[] = [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'requests' => $requests,
|
||||||
|
'local_only' => $requireFastLocalPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'batch_id' => 'batch-switch',
|
||||||
|
'status' => 'PENDING',
|
||||||
|
'items' => $requests,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('maps gateway relay status responses into the Shelly cloud payload shape', function (): void {
|
it('maps gateway relay status responses into the Shelly cloud payload shape', function (): void {
|
||||||
@@ -220,6 +262,46 @@ it('forwards Shelly toggle_after timers to gateway relay switches', function ():
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queues gateway relay batch status requests through the manager', function (): void {
|
||||||
|
$manager = new GatewayShellyTransportManagerFake();
|
||||||
|
$transport = new gateway_shelly_transport($manager);
|
||||||
|
|
||||||
|
$result = $transport->sendPostRequest('/v2/devices/api/batch/get', [
|
||||||
|
'commands' => [
|
||||||
|
['target' => 'MACHINE', 'relay_id' => 'relay-machine'],
|
||||||
|
['target' => 'ENTRANCE', 'relay_id' => 'relay-in'],
|
||||||
|
],
|
||||||
|
], 17);
|
||||||
|
|
||||||
|
expect($manager->batchStatusCalls)->toHaveCount(1)
|
||||||
|
->and($manager->batchStatusCalls[0]['department_id'])->toBe(17)
|
||||||
|
->and($manager->batchStatusCalls[0]['requests'])->toBe([
|
||||||
|
['target' => 'MACHINE', 'relay_id' => 'relay-machine'],
|
||||||
|
['target' => 'ENTRANCE', 'relay_id' => 'relay-in'],
|
||||||
|
])
|
||||||
|
->and($result['batch_id'])->toBe('batch-status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues gateway relay batch switch requests through the manager', function (): void {
|
||||||
|
$manager = new GatewayShellyTransportManagerFake();
|
||||||
|
$transport = new gateway_shelly_transport($manager, true);
|
||||||
|
|
||||||
|
$result = $transport->sendPostRequest('/v2/devices/api/batch/set/switch', [
|
||||||
|
'commands' => [
|
||||||
|
['target' => 'MACHINE', 'relay_id' => 'relay-machine', 'on' => true],
|
||||||
|
['target' => 'EXIT', 'relay_id' => 'relay-out', 'on' => true, 'toggle_after' => 1],
|
||||||
|
],
|
||||||
|
], 17);
|
||||||
|
|
||||||
|
expect($manager->batchSwitchCalls)->toHaveCount(1)
|
||||||
|
->and($manager->batchSwitchCalls[0]['local_only'])->toBeTrue()
|
||||||
|
->and($manager->batchSwitchCalls[0]['requests'])->toBe([
|
||||||
|
['target' => 'MACHINE', 'relay_id' => 'relay-machine', 'on' => true, 'toggle_after' => null],
|
||||||
|
['target' => 'EXIT', 'relay_id' => 'relay-out', 'on' => true, 'toggle_after' => 1],
|
||||||
|
])
|
||||||
|
->and($result['batch_id'])->toBe('batch-switch');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires a valid department id for gateway transport requests', function (): void {
|
it('requires a valid department id for gateway transport requests', function (): void {
|
||||||
$transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake());
|
$transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake());
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ function selfserve_module_config_database_config(): array
|
|||||||
};
|
};
|
||||||
|
|
||||||
$method = new ReflectionMethod($subject::class, 'readDatabaseConfigFromEnvironment');
|
$method = new ReflectionMethod($subject::class, 'readDatabaseConfigFromEnvironment');
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return $method->invoke(null);
|
return $method->invoke(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute
|
|||||||
|
|
||||||
protected function emitForbidden(array $permissions): void
|
protected function emitForbidden(array $permissions): void
|
||||||
{
|
{
|
||||||
$this->forbiddenPermissions = array_values($permissions);
|
$this->forbiddenPermissions = array_values(array_map(
|
||||||
|
static fn(string|\classes\permission_node $permission): string => $permission instanceof \classes\permission_node
|
||||||
|
? $permission->permission
|
||||||
|
: $permission,
|
||||||
|
$permissions
|
||||||
|
));
|
||||||
throw new RuntimeException('forbidden');
|
throw new RuntimeException('forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ it('authorizes customer eligibility preview lanes before returning task attachme
|
|||||||
|
|
||||||
expect($assertBlock)->toContain('bool $hasGlobalPermission = true')
|
expect($assertBlock)->toContain('bool $hasGlobalPermission = true')
|
||||||
->and($assertBlock)->toContain('bool $hasOwnPermission = false')
|
->and($assertBlock)->toContain('bool $hasOwnPermission = false')
|
||||||
->and($assertBlock)->toContain('if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane))')
|
->and($assertBlock)->toContain('if ($hasGlobalPermission && $user !== null && $this->userHasLaneDepartmentAccess($user, $lane))')
|
||||||
->and($assertBlock)->toContain('if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane))')
|
->and($assertBlock)->toContain('if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane))')
|
||||||
->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);')
|
->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);')
|
||||||
->and($assertBlock)->toContain('$response->forbidden([$elevatedPermission]);');
|
->and($assertBlock)->toContain('$response->forbidden([$elevatedPermission]);');
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ it('wires self-serve machine types, eligibility, summaries, and machine-start we
|
|||||||
expect($vehicleConditionsRoute)->toContain('/department/selfserve/vehicle/allowed');
|
expect($vehicleConditionsRoute)->toContain('/department/selfserve/vehicle/allowed');
|
||||||
expect($vehicleConditionsRoute)->toContain('/department/selfserve/washes/summary');
|
expect($vehicleConditionsRoute)->toContain('/department/selfserve/washes/summary');
|
||||||
expect($vehicleConditionsRoute)->toContain('synchronizeSession');
|
expect($vehicleConditionsRoute)->toContain('synchronizeSession');
|
||||||
|
expect($vehicleConditionsRoute)->toContain("definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST)");
|
||||||
|
expect($vehicleConditionsRoute)->toContain("definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD)");
|
||||||
|
expect($vehicleConditionsRoute)->toContain('getAuthenticatedSelfServePrincipal');
|
||||||
|
expect($vehicleConditionsRoute)->toContain('resolveEffectiveCustomerNumber()');
|
||||||
|
|
||||||
expect($webhookRoute)->not->toBeFalse();
|
expect($webhookRoute)->not->toBeFalse();
|
||||||
expect($webhookRoute)->toContain('/relay/button/press/post');
|
expect($webhookRoute)->toContain('/relay/button/press/post');
|
||||||
@@ -74,8 +78,12 @@ it('wires lane-level self-serve toggles through lane APIs, guest payloads, and e
|
|||||||
->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)');
|
->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)');
|
||||||
|
|
||||||
expect($guestRoute)->not->toBeFalse()
|
expect($guestRoute)->not->toBeFalse()
|
||||||
->and($guestRoute)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()")
|
->and($guestRoute)->toContain("\$additional_data['self_serve_module_enabled'] = \$self_serve_module_enabled")
|
||||||
->and($guestRoute)->toContain("'machine_available' => \$lane->isSelfServeEnabled() && !empty(\$lane->relay_machine_id->value())");
|
->and($guestRoute)->toContain("'self_serve_module_disabled'")
|
||||||
|
->and($guestRoute)->toContain("'selfserve_enabled' => \$lane_self_serve_enabled")
|
||||||
|
->and($guestRoute)->toContain("'selfserve_available' => \$self_serve_available")
|
||||||
|
->and($guestRoute)->toContain("'selfserve_unavailable_reason' => \$self_serve_unavailable_reason")
|
||||||
|
->and($guestRoute)->toContain("'machine_available' => \$self_serve_available && !empty(\$lane->relay_machine_id->value())");
|
||||||
|
|
||||||
expect($edgeWorkspace)->not->toBeFalse()
|
expect($edgeWorkspace)->not->toBeFalse()
|
||||||
->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()")
|
->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()")
|
||||||
@@ -159,12 +167,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
|
|||||||
expect($studioGraph)->toContain('resolved');
|
expect($studioGraph)->toContain('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wires machine wash included minutes into self-serve module config', function (): void {
|
it('wires machine wash controls into self-serve module config', function (): void {
|
||||||
$selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php'));
|
$selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php'));
|
||||||
|
$machineWashEnabledConfig = file_get_contents(app_path('modules/selfserve/config/selfserve_machine_wash_enabled_c.php'));
|
||||||
|
|
||||||
expect($selfserveConfig)->not->toBeFalse();
|
expect($selfserveConfig)->not->toBeFalse();
|
||||||
expect($selfserveConfig)->toContain('selfserve_machine_wash_minutes_included_c');
|
expect($selfserveConfig)->toContain('selfserve_machine_wash_minutes_included_c');
|
||||||
expect($selfserveConfig)->toContain('machine_wash_minutes_included');
|
expect($selfserveConfig)->toContain('machine_wash_minutes_included');
|
||||||
|
expect($selfserveConfig)->toContain('selfserve_machine_wash_enabled_c');
|
||||||
|
expect($selfserveConfig)->toContain('machine_wash_enabled');
|
||||||
|
expect($machineWashEnabledConfig)->not->toBeFalse()
|
||||||
|
->and($machineWashEnabledConfig)->toContain("'machine_wash_enabled'")
|
||||||
|
->and($machineWashEnabledConfig)->toContain("'bool'")
|
||||||
|
->and($machineWashEnabledConfig)->toContain('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps legacy self-serve CRUD routes syncing canonical drafts', function (): void {
|
it('keeps legacy self-serve CRUD routes syncing canonical drafts', function (): void {
|
||||||
@@ -185,6 +200,7 @@ it('wires machine relay status get and set endpoints', function (): void {
|
|||||||
|
|
||||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||||
expect($relayController)->not->toBeFalse();
|
expect($relayController)->not->toBeFalse();
|
||||||
|
expect($moduleSelfServeRoute)->toContain("definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST)");
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status');
|
||||||
@@ -206,6 +222,23 @@ it('wires machine relay status get and set endpoints', function (): void {
|
|||||||
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
|
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to edge gateway relay bindings for lane hardware batches', function (): void {
|
||||||
|
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||||
|
|
||||||
|
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||||
|
expect($moduleSelfServeRoute)
|
||||||
|
->toContain('use objects\\edge_gateway_relay_bindings_o;')
|
||||||
|
->toContain('edgeGatewayRelayIdForLaneHardwareTarget')
|
||||||
|
->toContain('$this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target)')
|
||||||
|
->toContain('edgeGatewayBindingMatchesHardwareTarget')
|
||||||
|
->toContain('edgeGatewayBindingMatchesLane')
|
||||||
|
->toContain('Multiple edge gateway relay bindings match lane hardware target')
|
||||||
|
->toContain("'MACHINE' => ['MACHINE']")
|
||||||
|
->toContain("'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER']")
|
||||||
|
->toContain("'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER']")
|
||||||
|
->toContain("'ENTRANCE' => ['ENTRANCE', 'ENTRY']");
|
||||||
|
});
|
||||||
|
|
||||||
it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void {
|
it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void {
|
||||||
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||||
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
|
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
|
||||||
@@ -278,6 +311,12 @@ it('wires self-serve lane gate open endpoint', function (): void {
|
|||||||
expect($moduleSelfServeRoute)->toContain('MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5');
|
expect($moduleSelfServeRoute)->toContain('MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5');
|
||||||
expect($moduleSelfServeRoute)->toContain('self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS);');
|
expect($moduleSelfServeRoute)->toContain('self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS);');
|
||||||
expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after)');
|
expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after)');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('queueLocalLaneGateOpen($lane, $gate, $toggle_after)');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('$this->requestedShellyTransportOverride() === \'local\'');
|
||||||
|
expect($moduleSelfServeRoute)->toContain('queueRelaySwitchBatch(');
|
||||||
|
expect($moduleSelfServeRoute)->toContain("'target' => \$gate->name");
|
||||||
|
expect($moduleSelfServeRoute)->toContain("'queued' => \$queued_gate_command !== null");
|
||||||
|
expect($moduleSelfServeRoute)->toContain('$queued_gate_command !== null ? 202 : 200');
|
||||||
expect($moduleSelfServeRoute)->toContain("'toggle_after' => \$toggle_after");
|
expect($moduleSelfServeRoute)->toContain("'toggle_after' => \$toggle_after");
|
||||||
expect($moduleSelfServeRoute)->toContain('private function requestedRelayToggleAfter');
|
expect($moduleSelfServeRoute)->toContain('private function requestedRelayToggleAfter');
|
||||||
expect($moduleSelfServeRoute)->toContain('Failed to open self-serve lane gate');
|
expect($moduleSelfServeRoute)->toContain('Failed to open self-serve lane gate');
|
||||||
@@ -400,6 +439,24 @@ it('filters machine button tasks out of self-serve snapshots when MACHINE is not
|
|||||||
->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null");
|
->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the global machine wash toggle before exposing machine tasks or services', function (): void {
|
||||||
|
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||||
|
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||||
|
|
||||||
|
expect($washFlow)->not->toBeFalse()
|
||||||
|
->and($washFlow)->toContain('$machineWashEnabled = $this->isMachineWashEnabled();')
|
||||||
|
->and($washFlow)->toContain('$allowedServices = $this->withoutMachineService($allowedServices);')
|
||||||
|
->and($washFlow)->toContain('$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());')
|
||||||
|
->and($washFlow)->toContain("'machine_wash_enabled' => \$machineWashEnabled")
|
||||||
|
->and($washFlow)->toContain('Machine wash is disabled globally.')
|
||||||
|
->and($washFlow)->toContain('->config->machine_wash_enabled->isTrue()');
|
||||||
|
|
||||||
|
expect($moduleSelfServeRoute)->not->toBeFalse()
|
||||||
|
->and($moduleSelfServeRoute)->toContain('if (!$this->isMachineWashEnabled())')
|
||||||
|
->and($moduleSelfServeRoute)->toContain('selfserve_lane_services::MACHINE->name')
|
||||||
|
->and($moduleSelfServeRoute)->toContain('->config->machine_wash_enabled->isTrue()');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void {
|
it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void {
|
||||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||||
$sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
|
$sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
|
||||||
|
|||||||
@@ -7,34 +7,60 @@ it('registers Slack module config endpoints and customer registration webhook co
|
|||||||
expect($routeContent)->not->toBeFalse()
|
expect($routeContent)->not->toBeFalse()
|
||||||
->and($routeContent)->toContain('/slack/config')
|
->and($routeContent)->toContain('/slack/config')
|
||||||
->and($routeContent)->toContain('/slack/config/test')
|
->and($routeContent)->toContain('/slack/config/test')
|
||||||
|
->and($routeContent)->toContain('/slack/config/internal-department-goal-progress')
|
||||||
|
->and($routeContent)->toContain('/slack/config/internal-department-goal-progress/test')
|
||||||
->and($routeContent)->toContain("requirePermission('slack_config')")
|
->and($routeContent)->toContain("requirePermission('slack_config')")
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()")
|
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()")
|
||||||
->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()");
|
->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()")
|
||||||
|
->and($routeContent)->toContain("(new slack())->test_internal_department_goal_progress_webhook()")
|
||||||
|
->and($routeContent)->toContain("(new slack())->get_internal_department_goal_progress_config()")
|
||||||
|
->and($routeContent)->toContain("set_internal_department_goal_progress_config");
|
||||||
|
|
||||||
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
||||||
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
||||||
|
$internalWebhookContent = file_get_contents(app_path('modules/slack/config/slack_internal_department_goal_progress_webhook_url_c.php'));
|
||||||
|
$internalDepartmentsContent = file_get_contents(app_path('modules/slack/config/slack_internal_department_ids_c.php'));
|
||||||
$slackClassContent = file_get_contents(app_path('classes/slack.php'));
|
$slackClassContent = file_get_contents(app_path('classes/slack.php'));
|
||||||
$authRouteContent = file_get_contents(app_path('routes/authRoute.php'));
|
$authRouteContent = file_get_contents(app_path('routes/authRoute.php'));
|
||||||
|
$departmentsContent = file_get_contents(app_path('objects/departments_o.php'));
|
||||||
$openApiContent = file_get_contents(app_path('openapi.yaml'));
|
$openApiContent = file_get_contents(app_path('openapi.yaml'));
|
||||||
|
|
||||||
expect($moduleContent)->not->toBeFalse()
|
expect($moduleContent)->not->toBeFalse()
|
||||||
->and($moduleContent)->toContain("setupConfig('Slack')")
|
->and($moduleContent)->toContain("setupConfig('Slack')")
|
||||||
->and($moduleContent)->toContain('slack_customer_registration_webhook_url_c::class')
|
->and($moduleContent)->toContain('slack_customer_registration_webhook_url_c::class')
|
||||||
|
->and($moduleContent)->toContain('slack_internal_department_goal_progress_webhook_url_c::class')
|
||||||
|
->and($moduleContent)->toContain('slack_internal_department_ids_c::class')
|
||||||
->and($variableContent)->not->toBeFalse()
|
->and($variableContent)->not->toBeFalse()
|
||||||
->and($variableContent)->toContain("'customer_registration_webhook_url'")
|
->and($variableContent)->toContain("'customer_registration_webhook_url'")
|
||||||
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
||||||
|
->and($internalWebhookContent)->not->toBeFalse()
|
||||||
|
->and($internalWebhookContent)->toContain("'internal_department_goal_progress_webhook_url'")
|
||||||
|
->and($internalDepartmentsContent)->not->toBeFalse()
|
||||||
|
->and($internalDepartmentsContent)->toContain("'internal_department_ids'")
|
||||||
|
->and($internalDepartmentsContent)->toContain('normalizeDepartmentIds')
|
||||||
->and($slackClassContent)->not->toBeFalse()
|
->and($slackClassContent)->not->toBeFalse()
|
||||||
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
||||||
->and($slackClassContent)->toContain('test_customer_registration_webhook')
|
->and($slackClassContent)->toContain('test_customer_registration_webhook')
|
||||||
|
->and($slackClassContent)->toContain('test_internal_department_goal_progress_webhook')
|
||||||
|
->and($slackClassContent)->toContain('get_internal_department_goal_progress_config')
|
||||||
|
->and($slackClassContent)->toContain('get_internal_department_goal_progress_webhook_url')
|
||||||
->and($slackClassContent)->toContain('format_customer_registration_test')
|
->and($slackClassContent)->toContain('format_customer_registration_test')
|
||||||
|
->and($slackClassContent)->toContain('format_internal_department_goal_progress_test')
|
||||||
->and($slackClassContent)->toContain('format_customer_registration')
|
->and($slackClassContent)->toContain('format_customer_registration')
|
||||||
->and($authRouteContent)->not->toBeFalse()
|
->and($authRouteContent)->not->toBeFalse()
|
||||||
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
||||||
|
->and($departmentsContent)->not->toBeFalse()
|
||||||
|
->and($departmentsContent)->toContain('$slack->get_internal_department_ids()')
|
||||||
|
->and($departmentsContent)->toContain('$slack->get_internal_department_goal_progress_webhook_url()')
|
||||||
|
->and($departmentsContent)->not->toContain('select(10)->slack_webhook')
|
||||||
->and($openApiContent)->not->toBeFalse()
|
->and($openApiContent)->not->toBeFalse()
|
||||||
->and($openApiContent)->toContain('/slack/config')
|
->and($openApiContent)->toContain('/slack/config')
|
||||||
->and($openApiContent)->toContain('/slack/config/test')
|
->and($openApiContent)->toContain('/slack/config/test')
|
||||||
|
->and($openApiContent)->toContain('/slack/config/internal-department-goal-progress')
|
||||||
|
->and($openApiContent)->toContain('/slack/config/internal-department-goal-progress/test')
|
||||||
->and($openApiContent)->toContain('SlackConfigListResponse')
|
->and($openApiContent)->toContain('SlackConfigListResponse')
|
||||||
|
->and($openApiContent)->toContain('SlackInternalDepartmentGoalProgressConfigResponse')
|
||||||
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
||||||
->and($openApiContent)->toContain('SlackConfigEntry');
|
->and($openApiContent)->toContain('SlackConfigEntry');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ final class SlackCustomerRegistrationWebhookFake extends slack
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $webhook,
|
private readonly string $webhook,
|
||||||
private readonly string $sendResult = 'Message sent successfully. Response: ok'
|
private readonly string $sendResult = 'Message sent successfully. Response: ok',
|
||||||
|
private readonly ?string $internalDepartmentGoalProgressWebhook = null
|
||||||
) {
|
) {
|
||||||
// Skip parent config loading for unit isolation.
|
// Skip parent config loading for unit isolation.
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,11 @@ final class SlackCustomerRegistrationWebhookFake extends slack
|
|||||||
return $this->webhook;
|
return $this->webhook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function get_internal_department_goal_progress_webhook_url(): string
|
||||||
|
{
|
||||||
|
return $this->internalDepartmentGoalProgressWebhook ?? $this->webhook;
|
||||||
|
}
|
||||||
|
|
||||||
public function send_webhook_message(string $message, string $webhook): string
|
public function send_webhook_message(string $message, string $webhook): string
|
||||||
{
|
{
|
||||||
$this->messages[] = [
|
$this->messages[] = [
|
||||||
@@ -84,3 +90,63 @@ it('reports customer registration test notification failures without exposing th
|
|||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not send internal department goal progress test notifications without a saved webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake(
|
||||||
|
'https://hooks.slack.test/services/customer-registration',
|
||||||
|
internalDepartmentGoalProgressWebhook: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $slack->test_internal_department_goal_progress_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => false,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack internal department goal progress webhook URL is not configured.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends internal department goal progress test notifications to the saved webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake(
|
||||||
|
'https://hooks.slack.test/services/customer-registration',
|
||||||
|
internalDepartmentGoalProgressWebhook: 'https://hooks.slack.test/services/internal-goals-secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $slack->test_internal_department_goal_progress_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => true,
|
||||||
|
'message' => 'Slack test message sent successfully.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toHaveCount(1)
|
||||||
|
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/internal-goals-secret')
|
||||||
|
->and($slack->messages[0]['message'])->toContain('Truck Wash Slack test')
|
||||||
|
->and($slack->messages[0]['message'])->toContain('Internal department goal progress notifications are configured correctly.')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('internal-goals-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports internal department goal progress test notification failures without exposing the webhook', function (): void {
|
||||||
|
$slack = new SlackCustomerRegistrationWebhookFake(
|
||||||
|
'https://hooks.slack.test/services/customer-registration',
|
||||||
|
'Failed to send message: cURL error for https://hooks.slack.test/services/internal-goals-secret',
|
||||||
|
'https://hooks.slack.test/services/internal-goals-secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $slack->test_internal_department_goal_progress_webhook();
|
||||||
|
|
||||||
|
expect($result)
|
||||||
|
->toBe([
|
||||||
|
'configured' => true,
|
||||||
|
'sent' => false,
|
||||||
|
'message' => 'Slack test message failed.',
|
||||||
|
])
|
||||||
|
->and($slack->messages)->toHaveCount(1)
|
||||||
|
->and(json_encode($result, JSON_UNESCAPED_SLASHES))->not->toContain('internal-goals-secret')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
||||||
|
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('internal-goals-secret');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
app_require('modules/slack/config/slack_internal_department_ids_c.php');
|
||||||
|
|
||||||
|
use slack\config\slack_internal_department_ids_c;
|
||||||
|
|
||||||
|
it('normalizes configured Slack internal department ids', function (): void {
|
||||||
|
expect(slack_internal_department_ids_c::normalizeDepartmentIds([3, '2', 2, 0, -1, 'abc', 1]))
|
||||||
|
->toBe([1, 2, 3])
|
||||||
|
->and(slack_internal_department_ids_c::normalizeDepartmentIds('[7, "5", 5]'))
|
||||||
|
->toBe([5, 7])
|
||||||
|
->and(slack_internal_department_ids_c::normalizeDepartmentIds('4, 2, invalid'))
|
||||||
|
->toBe([2, 4])
|
||||||
|
->and(slack_internal_department_ids_c::normalizeDepartmentIds('not-json'))
|
||||||
|
->toBe([]);
|
||||||
|
});
|
||||||
@@ -165,7 +165,7 @@ trait module_config_t
|
|||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (integer)$value;
|
return (int)$value;
|
||||||
}
|
}
|
||||||
// If the variable is a boolean, convert it to a boolean
|
// If the variable is a boolean, convert it to a boolean
|
||||||
if ($type == 'bool') {
|
if ($type == 'bool') {
|
||||||
|
|||||||
Reference in New Issue
Block a user