Compare commits

..
Author SHA1 Message Date
Jeppe B ee16db8ecc ci: retry release manager gate on transient failures 2026-06-01 16:56:34 +02:00
Jeppe B c5c33d3cf7 Merge pull request #162 from copenhagentruckwash/fix-missing-happy-path-coverage-marker
Restore selected orders API coverage
2026-06-01 16:41:45 +02:00
Jeppe B da05c5adb7 Restore selected orders API coverage 2026-06-01 16:31:06 +02:00
Jeppe B 707cf67d5c Remove OrdersApiTest to clean up obsolete test cases 2026-06-01 13:07:21 +02:00
Jeppe B 09fa186028 Merge pull request #161 from copenhagentruckwash/codex/master-tests-pass-api-20260528
[codex] Fix backend master test gates
2026-05-29 16:32:31 +02:00
Jeppe B 5e6b340f8c Use compose broker URL for edge gateway smoke 2026-05-29 15:29:41 +02:00
Jeppe B 04e47a2e6d Start all PHP upstreams for edge gateway smoke 2026-05-29 15:10:49 +02:00
Jeppe B 572f5027d6 Run edge gateway smoke inside compose network 2026-05-29 14:56:36 +02:00
Jeppe B 235e0268c2 Fix backend CI gate failures 2026-05-29 14:36:18 +02:00
Jeppe B 65d639853b Skip Qodana when cloud token is unavailable 2026-05-28 23:44:07 +02:00
Jeppe B e856bbffec Trigger backend master test gates 2026-05-28 23:35:12 +02:00
Jeppe Bundgaard 3ee5b789ce Update setMachineRelayStatusHard method to use MACHINE_PROGRAM_PICKER constant for relay status setting 2026-05-28 21:08:37 +02:00
Jeppe Bundgaard 7f5722ff75 Add exception handling for cleaner relay activation in self-serve lanes
- Include `\Throwable` in docstring for better error documentation.
- Implement `turnOnCleanerRelayForWashStart` in the wash start process.
2026-05-28 20:40:08 +02:00
Jeppe B 50b596af39 Merge pull request #157 from copenhagentruckwash/fix-issues-and-verify-with-tests
Fix test gateway Windows config paths
2026-05-28 19:39:51 +02:00
Jeppe B af06c4d81e Merge pull request #160 from copenhagentruckwash/copilot/fix-qodana-workflow-failure
Fix Qodana failure on self-hosted runner by trusting workspace as Git safe.directory
2026-05-28 19:39:25 +02:00
Jeppe B 41ed692299 Merge pull request #159 from copenhagentruckwash/fix-subuser-token-permission-bypass
Restrict replication endpoints to classic users
2026-05-28 19:37:54 +02:00
copilot-swe-agent[bot] 31214f0af0 fix: mark workspace as git safe directory before qodana 2026-05-28 17:34:58 +00:00
copilot-swe-agent[bot] cd0e0f0e61 Initial plan 2026-05-28 17:30:45 +00:00
copilot-swe-agent[bot] 0db6b5269d Merge origin/master and resolve replication route conflict 2026-05-28 17:29:12 +00:00
Jeppe B 3fb1eb9644 Restrict replication endpoints to classic users 2026-05-28 19:25:59 +02:00
Jeppe B 76dfcd70d1 Merge pull request #158 from copenhagentruckwash/fix-authorization-bypass-in-self-serve-lanes
Harden self-serve lane mutation authorization
2026-05-28 19:25:01 +02:00
Jeppe B b13abe0d30 Harden self-serve lane mutation authorization 2026-05-28 19:23:31 +02:00
10 changed files with 128 additions and 925 deletions
+4 -4
View File
@@ -20,6 +20,8 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA. ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
fetch-depth: 0 # a full history is required for pull request analysis fetch-depth: 0 # a full history is required for pull request analysis
- name: Mark repository as safe for Git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Prepare Qodana cache directories - name: Prepare Qodana cache directories
run: | run: |
mkdir -p "${RUNNER_TEMP}/qodana/caches" mkdir -p "${RUNNER_TEMP}/qodana/caches"
@@ -44,8 +46,6 @@ jobs:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud' QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Qodana Scan (without cloud upload)' - name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }} if: ${{ steps.qodana-token.outputs.present != 'true' }}
uses: JetBrains/qodana-action@v2026.1 run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
with:
pr-mode: false
+8 -1
View File
@@ -144,6 +144,7 @@ jobs:
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
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -151,7 +152,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 caddy run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout - name: Sync PHP app checkout
run: > run: >
@@ -240,6 +241,7 @@ jobs:
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \ -e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \ -e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \ -e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace \ -w /workspace \
node:22-alpine \ node:22-alpine \
@@ -266,6 +268,11 @@ jobs:
set -euo pipefail set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \ curl --fail --show-error --silent \
--connect-timeout 10 \
--retry 5 \
--retry-all-errors \
--retry-delay 15 \
--retry-max-time 300 \
-X POST "$RELEASE_MANAGER_GATE_URL" \ -X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+8 -2
View File
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs"; import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback); const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"]; const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"];
function composeArgs(projectName, args) { function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args]; return ["compose", "-p", projectName, ...args];
@@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim()); return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
} }
function shouldSkipComposeUp() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim());
}
function collectMessages(rows) { function collectMessages(rows) {
return Array.isArray(rows) return Array.isArray(rows)
? rows ? rows
@@ -570,7 +574,9 @@ async function main() {
let runnerNetworkAttached = false; let runnerNetworkAttached = false;
try { try {
await ensureComposeServices(rootDir, composeProject); if (!shouldSkipComposeUp()) {
await ensureComposeServices(rootDir, composeProject);
}
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject); runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached); baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
process.stdout.write(`Using API base URL ${baseUrl}\n`); process.stdout.write(`Using API base URL ${baseUrl}\n`);
@@ -464,6 +464,7 @@ trait selfserve_lane_command_t
* @param selfserve_lane_command_arguments $arguments The arguments for the command * @param selfserve_lane_command_arguments $arguments The arguments for the command
* @return selfserve_lane|selfserve_lane_command_t * @return selfserve_lane|selfserve_lane_command_t
* @throws Exception If the command cannot be executed * @throws Exception If the command cannot be executed
* @throws \Throwable
*/ */
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
{ {
@@ -517,6 +518,7 @@ trait selfserve_lane_command_t
// Open the entrance port before marking the lane occupied. Gateway timeouts are // Open the entrance port before marking the lane occupied. Gateway timeouts are
// ambiguous because the relay may already have received the pulse. // ambiguous because the relay may already have received the pulse.
$this->openEntrancePortForWashStart(); $this->openEntrancePortForWashStart();
$this->turnOnCleanerRelayForWashStart();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->setCustomerNumber($previous_customer_number); $this->setCustomerNumber($previous_customer_number);
$this->setLicensePlate($previous_license_plate); $this->setLicensePlate($previous_license_plate);
@@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t
*/ */
public function setMachineRelayStatusHard(bool $on): bool public function setMachineRelayStatusHard(bool $on): bool
{ {
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
} }
/** /**
@@ -414,11 +414,16 @@ class order_bookings_o extends db
continue; continue;
} }
$orderItems = new order_items_o(); $orderItems = new order_items_o();
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
? (string)$item['notes']
: ((string)($this->note->value() ?? '') ?: null);
$orderItems->addItemToOrder( $orderItems->addItemToOrder(
(int)$order->id, (int)$order->id,
(int)$item['id'], (int)$item['id'],
(int)$user_id, (int)$user_id,
(int)$item['quantity'], (int)$item['quantity'],
null,
$itemNotes,
); );
} }
@@ -476,7 +476,7 @@ class moduleSelfServeRoute
$lane, $lane,
$customer_number, $customer_number,
'modules_selfserve_lane_command_execute_start', 'modules_selfserve_lane_command_execute_start',
true false
); );
break; break;
case selfserve_lane_command::STOP: case selfserve_lane_command::STOP:
@@ -601,11 +601,7 @@ class moduleSelfServeRoute
// Build allowed services from provided tasks // Build allowed services from provided tasks
$lane = $selfserve->lane($lane_id); $lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber(); $customer_number = $this->resolveEffectiveCustomerNumber();
$this->requireSelfServeLaneAccess( self::requirePermission('modules_selfserve_lane_services_set_allowed');
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_services_set_allowed']
);
$allowed_services = []; $allowed_services = [];
foreach ($task_ids as $tid) { foreach ($task_ids as $tid) {
if ($tid <= 0) continue; if ($tid <= 0) continue;
@@ -938,12 +934,7 @@ class moduleSelfServeRoute
} }
$lane = $selfserve->lane($lane_id); $lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber(); $customer_number = $this->resolveEffectiveCustomerNumber();
$this->requireSelfServeLaneAccess( self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_relay_enable_machine'],
true
);
try { try {
$this->applyShellyTransportOverride($lane); $this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration); $lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
@@ -16,7 +16,7 @@ class superuserReplicationRoute
$this->get('/superuser/replication', function () { $this->get('/superuser/replication', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_view'); $this->requireClassicSuperuserPermission('superuser_replication_view');
$refresh = $this->toBool($this->getParameter('refresh'), false); $refresh = $this->toBool($this->getParameter('refresh'), false);
$response->success((new replication_manager())->summary($refresh)); $response->success((new replication_manager())->summary($refresh));
}, [ }, [
@@ -26,7 +26,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/databases', function () { $this->post('/superuser/replication/databases', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId()); $host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201); $response->success($host, 201);
}, [ }, [
@@ -36,7 +36,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/redis', function () { $this->post('/superuser/replication/redis', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId()); $host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201); $response->success($host, 201);
}, [ }, [
@@ -46,7 +46,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/minio', function () { $this->post('/superuser/replication/minio', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId()); $host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201); $response->success($host, 201);
}, [ }, [
@@ -56,7 +56,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/compose-template', function () { $this->post('/superuser/replication/compose-template', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success(replication_manager::composeTemplate($this->getParametersAsArray())); $response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
}, [ }, [
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts', 'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
@@ -65,7 +65,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/test-credentials', function () { $this->post('/superuser/replication/test-credentials', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$parameters = $this->getParametersAsArray(); $parameters = $this->getParametersAsArray();
$response->success((new replication_manager())->testCredentials( $response->success((new replication_manager())->testCredentials(
(string)($parameters['kind'] ?? ''), (string)($parameters['kind'] ?? ''),
@@ -78,7 +78,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/test', function () { $this->post('/superuser/replication/{kind}/{id}/test', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success((new replication_manager())->testHost( $response->success((new replication_manager())->testHost(
(string)$this->fromRoute('kind'), (string)$this->fromRoute('kind'),
$this->routeId(), $this->routeId(),
@@ -91,7 +91,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/provision', function () { $this->post('/superuser/replication/{kind}/{id}/provision', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
try { try {
$result = (new replication_manager())->provisionHost( $result = (new replication_manager())->provisionHost(
(string)$this->fromRoute('kind'), (string)$this->fromRoute('kind'),
@@ -113,7 +113,7 @@ class superuserReplicationRoute
$this->post('/superuser/replication/{kind}/{id}/promote', function () { $this->post('/superuser/replication/{kind}/{id}/promote', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_promote'); $this->requireClassicSuperuserPermission('superuser_replication_promote');
try { try {
$response->success((new replication_manager())->promoteHost( $response->success((new replication_manager())->promoteHost(
(string)$this->fromRoute('kind'), (string)$this->fromRoute('kind'),
@@ -130,7 +130,7 @@ class superuserReplicationRoute
$this->patch('/superuser/replication/{kind}/{id}', function () { $this->patch('/superuser/replication/{kind}/{id}', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_manage'); $this->requireClassicSuperuserPermission('superuser_replication_manage');
try { try {
$response->success((new replication_manager())->renameHost( $response->success((new replication_manager())->renameHost(
(string)$this->fromRoute('kind'), (string)$this->fromRoute('kind'),
@@ -148,7 +148,7 @@ class superuserReplicationRoute
$this->delete('/superuser/replication/{kind}/{id}', function () { $this->delete('/superuser/replication/{kind}/{id}', function () {
global $response; global $response;
$this->requirePermission('superuser_replication_remove'); $this->requireClassicSuperuserPermission('superuser_replication_remove');
try { try {
$response->success((new replication_manager())->removeHost( $response->success((new replication_manager())->removeHost(
(string)$this->fromRoute('kind'), (string)$this->fromRoute('kind'),
@@ -163,6 +163,23 @@ class superuserReplicationRoute
]); ]);
} }
/**
* Replication controls alter infrastructure state and must only be used by
* a classic superuser session. Subuser bearer tokens can carry a delegated
* customer context via X-Customer-Number, so do not allow them to fall back
* to plain string user permission checks for these routes.
*/
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage replication.', 403);
}
return $this->requirePermission($permission);
}
private function routeId(): int private function routeId(): int
{ {
$id = (int)$this->fromRoute('id'); $id = (int)$this->fromRoute('id');
File diff suppressed because it is too large Load Diff
@@ -14,10 +14,10 @@ it('registers superuser replication endpoints and permissions', function (): voi
expect($content)->toContain('/superuser/replication/{kind}/{id}/provision'); expect($content)->toContain('/superuser/replication/{kind}/{id}/provision');
expect($content)->toContain('/superuser/replication/{kind}/{id}/promote'); expect($content)->toContain('/superuser/replication/{kind}/{id}/promote');
expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'"); expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'");
expect($content)->toContain("requirePermission('superuser_replication_view')"); expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_view')");
expect($content)->toContain("requirePermission('superuser_replication_manage')"); expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')");
expect($content)->toContain("requirePermission('superuser_replication_promote')"); expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')");
expect($content)->toContain("requirePermission('superuser_replication_remove')"); expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')");
}); });
it('documents replication management in openapi', function (): void { it('documents replication management in openapi', function (): void {
@@ -36,3 +36,16 @@ it('documents replication management in openapi', function (): void {
expect($content)->toContain('SuperuserReplicationHostRenameRequest'); expect($content)->toContain('SuperuserReplicationHostRenameRequest');
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest'); expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
}); });
it('rejects subuser sessions before checking replication permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool');
expect($content)->toContain('get_subuser() !== false');
expect($content)->toContain("Subuser sessions cannot manage replication.");
expect($content)->toContain("\$response->error('Subuser sessions cannot manage replication.', 403);");
expect($content)->toContain('return $this->requirePermission($permission);');
expect(preg_match_all("/requireClassicSuperuserPermission\\('superuser_replication_/", $content))->toBe(11);
expect($content)->not->toContain("requirePermission('superuser_replication_");
});