Initialize merge conflict resolution plan
This commit is contained in:
+7
-1
@@ -1 +1,7 @@
|
||||
/docker-compose.yml
|
||||
/docker-compose.yml
|
||||
|
||||
# Runtime-generated replication bootstrap snapshots may contain infrastructure
|
||||
# metadata and encrypted/plaintext credential material. They must be
|
||||
# supplied at runtime via mounted storage, not baked into deployment images.
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap-*.json
|
||||
|
||||
@@ -46,6 +46,8 @@ jobs:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
QODANA_ENDPOINT: 'https://qodana.cloud'
|
||||
|
||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||
- name: 'Qodana Scan (without cloud upload)'
|
||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
|
||||
uses: JetBrains/qodana-action@v2026.1
|
||||
with:
|
||||
pr-mode: false
|
||||
|
||||
@@ -51,6 +51,7 @@ COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
RUN set -eux; \
|
||||
rm -f /var/www/html/storage/replication-bootstrap.json /var/www/html/storage/replication-bootstrap-*.json; \
|
||||
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
# CORS is handled at the edge by Traefik's headers middleware.
|
||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Replication bootstrap snapshots contain sensitive failover credentials.
|
||||
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||
respond @replication_bootstrap 404
|
||||
=======
|
||||
# Do not expose local replication bootstrap material from the public web root.
|
||||
@replicationBootstrap path /storage/replication-bootstrap.json
|
||||
respond @replicationBootstrap 404
|
||||
>>>>>>> refs/remotes/origin/master
|
||||
|
||||
# PHP handling via FastCGI to php-fpm pool
|
||||
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
# CORS is handled at the edge by Traefik's headers middleware.
|
||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Replication bootstrap snapshots contain sensitive failover credentials.
|
||||
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||
respond @replication_bootstrap 404
|
||||
=======
|
||||
# Do not expose local replication bootstrap material from the public web root.
|
||||
@replicationBootstrap path /storage/replication-bootstrap.json
|
||||
respond @replicationBootstrap 404
|
||||
>>>>>>> refs/remotes/origin/master
|
||||
|
||||
# PHP handling via FastCGI to php-fpm pool
|
||||
php_fastcgi php-staging:9000
|
||||
|
||||
@@ -58,6 +58,32 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
return "strict";
|
||||
}
|
||||
|
||||
function resolveSharedSecret(options = {}) {
|
||||
return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim();
|
||||
}
|
||||
|
||||
function requireSharedSecret(req, res, sharedSecret) {
|
||||
if (sharedSecret === "") {
|
||||
jsonResponse(res, 503, {
|
||||
ok: false,
|
||||
error: "Edge broker shared secret is not configured",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseScopes(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -150,7 +176,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const sharedSecret = resolveSharedSecret(options);
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
@@ -460,25 +486,19 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
shared_secret_required: Boolean(sharedSecret),
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,8 +541,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,53 @@ test("broker defaults to strict auth and fails closed when manager URL is missin
|
||||
}
|
||||
});
|
||||
|
||||
test("broker rejects protected HTTP endpoints when shared secret is missing", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "", commandTimeoutMs: 2000 });
|
||||
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));
|
||||
const agentMessages = collectMessages(agent);
|
||||
|
||||
const commandResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commandType: "SET_RELAY_STATE",
|
||||
payload: { relayId: "M-7", on: true },
|
||||
}),
|
||||
});
|
||||
const commandJson = await commandResponse.json();
|
||||
|
||||
assert.equal(commandResponse.status, 503);
|
||||
assert.equal(commandJson.ok, false);
|
||||
assert.equal(commandJson.shared_secret_required, true);
|
||||
assert.match(commandJson.error, /shared secret is not configured/);
|
||||
assert.equal(agentMessages.some((message) => message.type === "COMMAND"), false);
|
||||
|
||||
const diagnosticsResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
});
|
||||
const diagnosticsJson = await diagnosticsResponse.json();
|
||||
|
||||
assert.equal(diagnosticsResponse.status, 503);
|
||||
assert.equal(diagnosticsJson.shared_secret_required, true);
|
||||
|
||||
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
||||
method: "POST",
|
||||
});
|
||||
const syncJson = await syncResponse.json();
|
||||
|
||||
assert.equal(syncResponse.status, 503);
|
||||
assert.equal(syncJson.shared_secret_required, true);
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker dispatches commands to connected agents", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
||||
const address = await broker.listen(0);
|
||||
|
||||
@@ -112,14 +112,19 @@ class edge_broker_client
|
||||
throw new Exception('Edge broker URL is not configured');
|
||||
}
|
||||
|
||||
$sharedSecret = $this->resolveSharedSecret();
|
||||
if ($sharedSecret === '') {
|
||||
throw new Exception('Edge broker shared secret is not configured');
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_filter([
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
$this->resolveSharedSecret() !== '' ? 'X-Edge-Broker-Secret: ' . $this->resolveSharedSecret() : null,
|
||||
])));
|
||||
'X-Edge-Broker-Secret: ' . $sharedSecret,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$rawResponse = curl_exec($ch);
|
||||
|
||||
@@ -35,6 +35,9 @@ class release_manager
|
||||
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
|
||||
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
|
||||
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
||||
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
|
||||
private const RELEASE_GATE_MAX_PATHS = 10;
|
||||
private const RELEASE_GATE_MAX_ASSETS = 50;
|
||||
private const RELEASE_API_RUNTIME_ENV_KEYS = [
|
||||
'USE_ENV',
|
||||
'DEBUG',
|
||||
@@ -1031,9 +1034,10 @@ class release_manager
|
||||
'api_ping_paths' => $this->releaseGateStringArray(
|
||||
$input['api_ping_paths']
|
||||
?? $input['api_paths']
|
||||
?? ['/master/api/ping']
|
||||
?? ['/master/api/ping'],
|
||||
self::RELEASE_GATE_MAX_PATHS
|
||||
),
|
||||
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']),
|
||||
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1133,7 +1137,7 @@ class release_manager
|
||||
return $steps;
|
||||
}
|
||||
|
||||
private function releaseGateStringArray(mixed $value): array
|
||||
private function releaseGateStringArray(mixed $value, int $limit = 50): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = preg_split('/\s*,\s*/', trim($value)) ?: [];
|
||||
@@ -1149,7 +1153,7 @@ class release_manager
|
||||
$values[] = $item;
|
||||
}
|
||||
}
|
||||
return $values;
|
||||
return array_slice($values, 0, max(0, $limit));
|
||||
}
|
||||
|
||||
private function normalizeReleaseGateUrl(string $value): string
|
||||
@@ -1389,14 +1393,14 @@ class release_manager
|
||||
}
|
||||
}
|
||||
|
||||
$assetUrls = $this->releaseGateUniqueStrings(array_merge(
|
||||
$assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
|
||||
['release-manifest.json', 'release-entry.json'],
|
||||
[(string)($manifestData['entry'] ?? '')],
|
||||
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
|
||||
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
|
||||
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
|
||||
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
|
||||
));
|
||||
)), 0, self::RELEASE_GATE_MAX_ASSETS);
|
||||
$verifiedAssets = 0;
|
||||
foreach ($assetUrls as $assetUrl) {
|
||||
if ($assetUrl === '/index.html') {
|
||||
@@ -1515,15 +1519,18 @@ class release_manager
|
||||
|
||||
private function releaseGateFetch(string $url): array
|
||||
{
|
||||
$this->assertReleaseGateFetchUrlAllowed($url);
|
||||
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Could not initialize release gate request.');
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
|
||||
curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 8);
|
||||
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Accept: application/json, text/html, */*',
|
||||
@@ -1552,13 +1559,82 @@ class release_manager
|
||||
|
||||
private function releaseGateJoinUrl(string $baseUrl, string $path): string
|
||||
{
|
||||
if (preg_match('#^https?://#i', $path) === 1) {
|
||||
return $path;
|
||||
$path = trim($path);
|
||||
$parts = parse_url($path);
|
||||
if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
|
||||
throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
|
||||
}
|
||||
if (str_starts_with($path, '//')) {
|
||||
throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
|
||||
private function assertReleaseGateFetchUrlAllowed(string $url): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
|
||||
}
|
||||
if (!$this->releaseGateFetchHostAllowed($host)) {
|
||||
throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
|
||||
}
|
||||
|
||||
$addresses = $this->releaseGateResolveHost($host);
|
||||
if ($addresses === []) {
|
||||
throw new RuntimeException('Release gate host could not be resolved.');
|
||||
}
|
||||
foreach ($addresses as $address) {
|
||||
if (!$this->releaseGatePublicIpAllowed($address)) {
|
||||
throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseGateFetchHostAllowed(string $host): bool
|
||||
{
|
||||
$host = strtolower(rtrim($host, '.'));
|
||||
foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
|
||||
$allowedSuffix = strtolower($allowedSuffix);
|
||||
if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function releaseGateResolveHost(string $host): array
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return [$host];
|
||||
}
|
||||
|
||||
$addresses = gethostbynamel($host) ?: [];
|
||||
if (function_exists('dns_get_record')) {
|
||||
foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
|
||||
if (is_array($record) && !empty($record['ipv6'])) {
|
||||
$addresses[] = (string)$record['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
|
||||
}
|
||||
|
||||
private function releaseGatePublicIpAllowed(string $address): bool
|
||||
{
|
||||
return filter_var(
|
||||
$address,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false;
|
||||
}
|
||||
|
||||
private function releaseGateCommitMatches(string $actual, string $expected): bool
|
||||
{
|
||||
$expected = strtolower(trim($expected));
|
||||
|
||||
@@ -5,6 +5,21 @@ $isPreview = $_GET['preview'] ?? false;
|
||||
// Remove query string if present
|
||||
$file = strtok($file, '?');
|
||||
|
||||
// Require authentication for direct /files/ access
|
||||
if (str_contains($file, '/files/')) {
|
||||
$headers = getallheaders();
|
||||
$token = $_GET['token'] ?? $_POST['token'] ?? ($headers['Authorization'] ?? null);
|
||||
if (!empty($token)) {
|
||||
$token = str_replace('Bearer ', '', $token);
|
||||
}
|
||||
|
||||
if (empty($token) || !(new \classes\authentication())->validate_token($token)) {
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
echo 'Unauthorized';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$isPDF = false;
|
||||
$isPDFStore = false;
|
||||
$isAttachment = false;
|
||||
@@ -40,20 +55,6 @@ if ($isPDF && $isPDFStore) {
|
||||
|
||||
// Check if the certificate exists
|
||||
if (!$wash_certificate_store->isFileInStore($file)) {
|
||||
// Try the PDF store
|
||||
$pdf_store = new \classes\pdf_store();
|
||||
if ($pdf_store->isFileInStore(str_replace('/files/', '', $file))) {
|
||||
// Download the certificate from the PDF store to /tmp
|
||||
$certificate_path = $pdf_store->download(str_replace('/files/', '', $file));
|
||||
// Send the certificate to the client
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . str_replace('/files/', '', $file) . '"');
|
||||
header('Content-Length: ' . filesize($certificate_path));
|
||||
readfile($certificate_path);
|
||||
// Delete the certificate from /tmp after sending it
|
||||
unlink($certificate_path);
|
||||
exit;
|
||||
}
|
||||
echo 'Certificate not found in store' . $file;
|
||||
//header('HTTP/1.1 404 Not Found');
|
||||
exit;
|
||||
@@ -118,4 +119,4 @@ if (!$isPDF) {
|
||||
// Delete the file from /tmp after sending it
|
||||
unlink($file_path);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,11 +237,6 @@ if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// If the route ends with .php, then require the file_server.php
|
||||
if (str_contains($_SERVER['REQUEST_URI'], '.pdf')) {
|
||||
require_once 'file_server.php';
|
||||
exit;
|
||||
}
|
||||
// If the route ends with a MIME type, then require the file_server.php
|
||||
if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
|
||||
require_once 'file_server.php';
|
||||
|
||||
@@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t
|
||||
*/
|
||||
public function setMachineRelayStatusHard(bool $on): bool
|
||||
{
|
||||
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
|
||||
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1446,3 +1446,34 @@ it('normalizes release assignment subject suggestions without leaking private fi
|
||||
'title' => 'Invalid',
|
||||
]))->toBeNull();
|
||||
});
|
||||
|
||||
it('restricts release gate fetches to Truckwash release hosts and relative paths', function (): void {
|
||||
$manager = new release_manager();
|
||||
|
||||
$joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl');
|
||||
$joinUrl->setAccessible(true);
|
||||
$hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed');
|
||||
$hostAllowed->setAccessible(true);
|
||||
$publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed');
|
||||
$publicIpAllowed->setAccessible(true);
|
||||
$stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray');
|
||||
$stringArray->setAccessible(true);
|
||||
|
||||
expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping'))
|
||||
->toBe('https://api-v2.truckwash.io/master/api/ping')
|
||||
->and($hostAllowed->invoke($manager, 'api-v2.truckwash.io'))->toBeTrue()
|
||||
->and($hostAllowed->invoke($manager, 'assets.canary.truckwash.io'))->toBeTrue()
|
||||
->and($hostAllowed->invoke($manager, 'truckwash.io.evil.test'))->toBeFalse()
|
||||
->and($hostAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
|
||||
->and($publicIpAllowed->invoke($manager, '8.8.8.8'))->toBeTrue()
|
||||
->and($publicIpAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
|
||||
->and($publicIpAllowed->invoke($manager, '10.0.0.5'))->toBeFalse()
|
||||
->and($publicIpAllowed->invoke($manager, '169.254.169.254'))->toBeFalse()
|
||||
->and($publicIpAllowed->invoke($manager, '::1'))->toBeFalse()
|
||||
->and($stringArray->invoke($manager, ['/a', '/b', '/c'], 2))->toBe(['/a', '/b']);
|
||||
|
||||
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', 'http://127.0.0.1/ping'))
|
||||
->toThrow(RuntimeException::class, 'relative');
|
||||
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
|
||||
->toThrow(RuntimeException::class, 'relative');
|
||||
});
|
||||
|
||||
@@ -11,3 +11,25 @@ it('uses default broker url but requires explicit shared secret configuration',
|
||||
expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')");
|
||||
expect($source)->not->toContain('DEFAULT_SHARED_SECRET');
|
||||
});
|
||||
|
||||
|
||||
it('fails closed when no broker shared secret is configured', function (): void {
|
||||
$previousBrokerSecret = getenv('EDGE_BROKER_SHARED_SECRET');
|
||||
$previousInternalSecret = getenv('EDGE_INTERNAL_SECRET');
|
||||
putenv('EDGE_BROKER_SHARED_SECRET');
|
||||
putenv('EDGE_INTERNAL_SECRET');
|
||||
|
||||
try {
|
||||
$client = new \classes\edge_broker_client('http://127.0.0.1:9', null, 1);
|
||||
|
||||
expect(fn() => $client->dispatchCommand(1, 'SET_RELAY_STATE', ['on' => true]))
|
||||
->toThrow(Exception::class, 'Edge broker shared secret is not configured');
|
||||
} finally {
|
||||
$previousBrokerSecret === false
|
||||
? putenv('EDGE_BROKER_SHARED_SECRET')
|
||||
: putenv('EDGE_BROKER_SHARED_SECRET=' . $previousBrokerSecret);
|
||||
$previousInternalSecret === false
|
||||
? putenv('EDGE_INTERNAL_SECRET')
|
||||
: putenv('EDGE_INTERNAL_SECRET=' . $previousInternalSecret);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -332,6 +332,20 @@ it('keeps back-to-back get/switch requests ordered through the relay controller'
|
||||
expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
|
||||
});
|
||||
|
||||
it('hard MACHINE relay helper targets the configured MACHINE relay', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
|
||||
$result = $harness->setMachineRelayStatusHard(true);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
||||
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
||||
'id' => 'relay-machine',
|
||||
'channel' => 0,
|
||||
'on' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('executes sequential switch requests used by wash start and stop flows', function (): void {
|
||||
$harness = selfserve_lane_shelly_test_harness();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user