Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/docker-web-runtime-smoke.sh
T

936 lines
41 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
DOCKER_SMOKE_RUN_ID="${DOCKER_SMOKE_RUN_ID:-$$}"
if [[ -z "$DOCKER_SMOKE_RUN_ID" || ${#DOCKER_SMOKE_RUN_ID} -gt 80 || ! $DOCKER_SMOKE_RUN_ID =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "DOCKER_SMOKE_RUN_ID must contain 1-80 Docker-safe characters" >&2
exit 2
fi
# shellcheck source=scripts/lib/docker-rls-role-fixture.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/docker-rls-role-fixture.sh"
# shellcheck source=scripts/lib/docker-runtime-smoke.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/docker-runtime-smoke.sh"
IMAGE="${1:-hivegrid-web:smoke}"
PORT="${DOCKER_WEB_SMOKE_PORT:-}"
ADMIN_PORT="${DOCKER_WEB_SMOKE_ADMIN_PORT:-}"
PORTAL_PORT="${DOCKER_WEB_SMOKE_PORTAL_PORT:-}"
CORE_CONTAINER_NAME="${DOCKER_WEB_SMOKE_CONTAINER:-hivegrid-web-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
ADMIN_CONTAINER_NAME="${DOCKER_WEB_SMOKE_ADMIN_CONTAINER:-hivegrid-admin-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
PORTAL_CONTAINER_NAME="${DOCKER_WEB_SMOKE_PORTAL_CONTAINER:-hivegrid-portal-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
CONTAINER_NAME="$CORE_CONTAINER_NAME"
DB_CONTAINER_NAME="${DOCKER_WEB_SMOKE_DB_CONTAINER:-hivegrid-web-runtime-smoke-db-${DOCKER_SMOKE_RUN_ID}}"
REDIS_CONTAINER_NAME="${DOCKER_WEB_SMOKE_REDIS_CONTAINER:-hivegrid-web-runtime-smoke-redis-${DOCKER_SMOKE_RUN_ID}}"
NETWORK_NAME="${DOCKER_WEB_SMOKE_NETWORK:-hivegrid-web-runtime-smoke-${DOCKER_SMOKE_RUN_ID}}"
SQL_ARTIFACT_CONTAINER_NAME="${DOCKER_WEB_SMOKE_SQL_ARTIFACT_CONTAINER:-hivegrid-web-runtime-smoke-sql-artifact-${DOCKER_SMOKE_RUN_ID}}"
PASSWORD_HASH_CONTAINER_NAME="${DOCKER_WEB_SMOKE_PASSWORD_HASH_CONTAINER:-hivegrid-web-runtime-smoke-password-hash-${DOCKER_SMOKE_RUN_ID}}"
MIGRATION_CONTAINER_NAME="${DOCKER_WEB_SMOKE_MIGRATION_CONTAINER:-hivegrid-web-runtime-smoke-migrate-${DOCKER_SMOKE_RUN_ID}}"
DB_PUSH_CONTAINER_NAME="${DOCKER_WEB_SMOKE_DB_PUSH_CONTAINER:-hivegrid-web-runtime-smoke-db-push-${DOCKER_SMOKE_RUN_ID}}"
TRANSPORT="${DOCKER_WEB_SMOKE_TRANSPORT:-host}"
SMOKE_REVISION="${DOCKER_WEB_SMOKE_REVISION:-}"
HEALTH_ATTEMPTS="${DOCKER_WEB_SMOKE_HEALTH_ATTEMPTS:-600}"
DB_PASSWORD="hivegrid"
DB_NAME="hivegrid"
DB_USER="hivegrid"
APP_DB_PASSWORD="hivegrid-app-smoke"
WORKER_DB_PASSWORD="hivegrid-worker-smoke"
MIGRATOR_DB_PASSWORD="hivegrid-migrator-smoke"
AUTHENTIK_JIT_DB_PASSWORD="hivegrid-authentik-jit-smoke"
SMOKE_LOGIN_TENANT_ID="docker-runtime-login-tenant"
SMOKE_LOGIN_TENANT_SLUG="docker-runtime-login"
SMOKE_LOGIN_USER_ID="docker-runtime-login-user"
SMOKE_LOGIN_EMAIL="docker.runtime.login.01@mitarbeiter.example.test"
SMOKE_LOGIN_PASSWORD='Docker-Runtime-Smoke-Only-2026!'
CATALOG_SENTINEL_TENANT_ID="docker-catalog-sentinel-tenant"
CATALOG_SENTINEL_ROLE_ID="docker-catalog-sentinel-role"
CATALOG_SENTINEL_GRANT_ID="docker-catalog-sentinel-grant"
CATALOG_STALE_GRANT_ID="docker-catalog-stale-global-grant"
SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY='BHzyexiNA09-ilI4AwS1GsPAiWnid_IbNaYLSPxHZpl4B3dVENuO0EApPZrGn3Qw27p9reY86YIpngS3nSJ4c9E'
SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64='BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc='
cleanup() {
docker_smoke_cleanup_container "web:cleanup:containers" \
"$CORE_CONTAINER_NAME" "$ADMIN_CONTAINER_NAME" "$PORTAL_CONTAINER_NAME" "$DB_CONTAINER_NAME" "$REDIS_CONTAINER_NAME" \
"$SQL_ARTIFACT_CONTAINER_NAME" "$PASSWORD_HASH_CONTAINER_NAME" "$MIGRATION_CONTAINER_NAME" "$DB_PUSH_CONTAINER_NAME"
docker_smoke_cleanup_network "web:cleanup:network" "$NETWORK_NAME"
}
emit_runtime_logs() {
local container_name="$1"
docker_smoke_logs "web:runtime-logs" "$container_name" >&2 || true
}
resolve_smoke_revision() {
if [[ -n "$SMOKE_REVISION" ]]; then
printf "%s" "$SMOKE_REVISION"
return
fi
docker_smoke_probe "web:image-revision" \
docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE"
}
http_status() {
local host="$1"
local path="$2"
local code
if [[ "$TRANSPORT" == "exec" ]]; then
code="$(docker_smoke_probe "web:http-status" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (response) => { process.stdout.write(String(response.statusCode ?? "000")); response.resume(); }); request.on("error", () => process.stdout.write("000")); request.end();' "$host" "$path" 2>/dev/null || true)"
else
code="$(docker_smoke_probe "web:http-status" curl -s -o /dev/null -w "%{http_code}" -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" 2>/dev/null || true)"
fi
if [[ -z "$code" ]]; then
printf "000"
return
fi
printf "%s" "$code"
}
http_body() {
local host="$1"
local path="$2"
if [[ "$TRANSPORT" == "exec" ]]; then
docker_smoke_probe "web:http-body" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); response.on("end", () => process.stdout.write(body)); }); request.on("error", () => process.exit(1)); request.end();' "$host" "$path"
else
docker_smoke_probe "web:http-body" curl --fail --silent --show-error -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}"
fi
}
http_header() {
local host="$1"
local path="$2"
local header_name="$3"
if [[ "$TRANSPORT" == "exec" ]]; then
docker_smoke_probe "web:http-header" docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path, headerName] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, method: "HEAD", headers: { Host: host } }, (response) => { const value = response.headers[headerName.toLowerCase()]; process.stdout.write(Array.isArray(value) ? value.join(", ") : value ?? ""); response.resume(); }); request.on("error", () => process.exit(1)); request.end();' "$host" "$path" "$header_name"
else
docker_smoke_probe "web:http-header" curl --fail --silent --show-error --head -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" \
| tr -d '\r' \
| awk -F': *' -v expected="$(printf '%s' "$header_name" | tr '[:upper:]' '[:lower:]')" 'tolower($1) == expected { sub(/^[^:]+:[[:space:]]*/, ""); print; exit }'
fi
}
expect_service_worker_csp() {
local host="$1"
local policy
policy="$(http_header "$host" /sw.js Content-Security-Policy)"
if [[ "$policy" != *"default-src 'none'"* || "$policy" != *"connect-src 'self'"* || "$policy" == *"connect-src 'none'"* ]]; then
echo "Expected ${host}/sw.js to allow only same-origin worker fetches, got ${policy:-<empty>}" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
expect_service_worker_recovery_bootstrap() {
local host="$1"
local body
body="$(http_body "$host" /login)"
if ! printf '%s' "$body" | node -e 'const fs = require("node:fs"); const html = fs.readFileSync(0, "utf8"); const markerIndex = html.indexOf("service-worker-csp-recovery:v3"); const stylesheetIndex = html.indexOf("rel=\"stylesheet\""); const scriptStart = html.lastIndexOf("<script", markerIndex); const scriptTagEnd = html.indexOf(">", scriptStart); const scriptTag = scriptStart >= 0 && scriptTagEnd > scriptStart ? html.slice(scriptStart, scriptTagEnd + 1) : ""; if (markerIndex < 0 || stylesheetIndex <= markerIndex || !/\snonce="[^"]+"/.test(scriptTag)) process.exit(1);'; then
echo "Expected ${host}/login to emit the nonce-protected worker recovery before stylesheet discovery" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
expect_status() {
local host="$1"
local path="$2"
local expected="$3"
local actual
actual="$(http_status "$host" "$path")"
if [[ "$actual" != "$expected" ]]; then
echo "Expected ${host}${path} to return ${expected}, got ${actual}" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
expect_health_revision() {
local host="$1"
local path="$2"
local body
local actual
body="$(http_body "$host" "$path")"
actual="$(printf "%s" "$body" | node -e 'const fs = require("node:fs"); try { const payload = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(payload.revision || ""); } catch { process.exit(1); }')"
if [[ "$actual" != "$SMOKE_REVISION" ]]; then
echo "Expected ${host}${path} revision ${SMOKE_REVISION}, got ${actual:-<empty>}" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
expect_runtime_payload() {
local host="$1"
local path="$2"
local expected_status="$3"
local expected_service="$4"
local expected_surface="$5"
local body
body="$(http_body "$host" "$path")"
if ! printf "%s" "$body" | node -e '
const fs = require("node:fs");
const [expectedStatus, expectedService, expectedSurface, expectedRevision] = process.argv.slice(1);
try {
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
if (
payload.status !== expectedStatus ||
payload.service !== expectedService ||
payload.surface !== expectedSurface ||
payload.revision !== expectedRevision
) process.exit(1);
} catch {
process.exit(1);
}
' "$expected_status" "$expected_service" "$expected_surface" "$SMOKE_REVISION"; then
echo "Expected ${host}${path} to report status=${expected_status}, service=${expected_service}, surface=${expected_surface}, revision=${SMOKE_REVISION}" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
wait_for_runtime_health() {
local host="$1"
local label="$2"
for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do
HTTP_STATUS="$(http_status "$host" /healthz)"
CONTAINER_HEALTH="$(docker_smoke_probe "web:health-inspect" docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$CONTAINER_NAME" 2>/dev/null || true)"
if [[ "$HTTP_STATUS" == "200" && "$CONTAINER_HEALTH" == "healthy" ]]; then
return
fi
if [[ "$(docker_smoke_probe "web:running-inspect" docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" != "true" ]]; then
echo "${label} runtime container exited during boot" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
if [[ "$CONTAINER_HEALTH" == "unhealthy" ]]; then
echo "${label} container Docker HEALTHCHECK became unhealthy with HTTP status ${HTTP_STATUS}" >&2
docker_smoke_run "web:health-diagnostic" "$DOCKER_SMOKE_LOG_TIMEOUT_SECONDS" \
docker inspect -f '{{json .State.Health}}' "$CONTAINER_NAME" >&2 || true
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
if [[ "$attempt" == "$HEALTH_ATTEMPTS" ]]; then
echo "${label} container did not become HTTP- and Docker-healthy on port ${PORT} after ${HEALTH_ATTEMPTS}s" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
sleep 1
done
}
assert_runtime_boot_is_mutation_free() {
local label="$1"
local boot_logs
docker_smoke_capture boot_logs "web:runtime-logs:capture" "$DOCKER_SMOKE_LOG_TIMEOUT_SECONDS" \
docker logs --tail "$DOCKER_SMOKE_LOG_TAIL" "$CONTAINER_NAME"
if grep -Eq '^\[(?:boot|migrator)\]' <<<"$boot_logs"; then
echo "${label} runtime unexpectedly executed migration or seed work" >&2
exit 1
fi
if grep -q '\[DEP0169\]' <<<"$boot_logs"; then
echo "${label} container used Node's deprecated url.parse() runtime path" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
apply_image_sql_artifact() {
local artifact_path="$1"
local stage="$2"
docker_smoke_run "$stage" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" bash -o pipefail -c '
docker run --rm --name "$1" --entrypoint /bin/sh "$2" \
-c "test -f \"\$1\" && cat \"\$1\"" _ "$6" \
| docker exec -e "PGPASSWORD=$3" -e PGOPTIONS="--client-min-messages=warning" -i "$4" \
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U hivegrid_migrator -d "$5" >/dev/null
' _ "$SQL_ARTIFACT_CONTAINER_NAME" "$IMAGE" "$MIGRATOR_DB_PASSWORD" "$DB_CONTAINER_NAME" "$DB_NAME" "$artifact_path"
}
seed_password_login_fixture() {
local password_hash
# Generate the fixture hash with argon2 from the exact image under test. The
# command substitution deliberately captures the hash so neither credential
# material nor the hash is written to smoke logs.
docker_smoke_capture password_hash "web:password-hash" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run --rm --name "$PASSWORD_HASH_CONTAINER_NAME" --entrypoint node "$IMAGE" -e '
import("argon2")
.then(({ default: argon2 }) => argon2.hash(process.argv[1], {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
}))
.then((hash) => process.stdout.write(hash))
.catch(() => process.exit(1));
' "$SMOKE_LOGIN_PASSWORD"
if [[ "$password_hash" != '$argon2id$'* ]]; then
echo "Exact web image failed to generate the password-login fixture hash" >&2
exit 1
fi
docker_smoke_run "web:seed-login-fixture" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \
-U "$DB_USER" -d "$DB_NAME" \
-v smoke_password_hash="$password_hash" \
-v smoke_tenant_id="$SMOKE_LOGIN_TENANT_ID" \
-v smoke_tenant_slug="$SMOKE_LOGIN_TENANT_SLUG" \
-v smoke_user_id="$SMOKE_LOGIN_USER_ID" \
-v smoke_email="$SMOKE_LOGIN_EMAIL" >/dev/null <<'SQL'
INSERT INTO "Tenant" ("id", "updatedAt", "slug", "name", "active")
VALUES (:'smoke_tenant_id', now(), :'smoke_tenant_slug', 'Docker runtime login', true);
INSERT INTO "User" ("id", "updatedAt", "email", "passwordHash", "firstName", "lastName", "defaultTenantId", "active")
VALUES (:'smoke_user_id', now(), :'smoke_email', :'smoke_password_hash', 'Docker', 'Runtime', :'smoke_tenant_id', true);
INSERT INTO "TenantUser" ("id", "tenantId", "userId", "type", "joined", "status")
VALUES ('docker-runtime-login-membership', :'smoke_tenant_id', :'smoke_user_id', 0, 0, 2);
SQL
}
expect_password_login_session() {
if ! docker_smoke_run "web:password-login" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" docker exec \
-e SMOKE_LOGIN_EMAIL="$SMOKE_LOGIN_EMAIL" \
-e SMOKE_LOGIN_PASSWORD="$SMOKE_LOGIN_PASSWORD" \
"$CONTAINER_NAME" node -e '
const http = require("node:http");
function request(options, body) {
return new Promise((resolve, reject) => {
const req = http.request(
{ hostname: "127.0.0.1", port: 8080, ...options },
(response) => {
let responseBody = "";
response.setEncoding("utf8");
response.on("data", (chunk) => { responseBody += chunk; });
response.on("end", () => resolve({
status: response.statusCode ?? 0,
headers: response.headers,
body: responseBody,
}));
},
);
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}
(async () => {
const loginPage = await request({
path: "/login",
method: "GET",
headers: { Host: "app.localhost", Accept: "text/html" },
});
const csrf = loginPage.body.match(/<meta[^>]+name=["\x27]csrf-token["\x27][^>]+content=["\x27]([^"\x27]+)["\x27]/i)?.[1];
const loginPageCookies = loginPage.headers["set-cookie"] ?? [];
const cookieHeader = loginPageCookies.map((value) => value.split(";", 1)[0]).join("; ");
if (loginPage.status !== 200 || !csrf || !cookieHeader) {
throw new Error("login bootstrap did not provide status, CSRF, and cookies");
}
const form = new URLSearchParams({
email: process.env.SMOKE_LOGIN_EMAIL,
password: process.env.SMOKE_LOGIN_PASSWORD,
redirectTo: "",
csrf,
}).toString();
const loginResponse = await request({
path: "/login",
method: "POST",
headers: {
Host: "app.localhost",
Origin: "http://app.localhost",
Referer: "http://app.localhost/login",
Accept: "text/html,application/xhtml+xml",
Cookie: cookieHeader,
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(form),
},
}, form);
const responseCookies = loginResponse.headers["set-cookie"] ?? [];
const hasAuthenticatedCookie = responseCookies.some((value) => value.startsWith("__Host-hivegrid_app_session="));
const location = loginResponse.headers.location ?? "";
if (![302, 303].includes(loginResponse.status) || !hasAuthenticatedCookie || !location || location.includes("/login")) {
throw new Error(`login did not create an authenticated redirect (status ${loginResponse.status})`);
}
})().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : "password login failed"}\n`);
process.exit(1);
});
' >/dev/null; then
echo "Expected exact web image to complete password login under required RLS" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
local active_session_count
docker_smoke_capture active_session_count "web:password-session" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \
-U "$DB_USER" -d "$DB_NAME" \
-v smoke_user_id="$SMOKE_LOGIN_USER_ID" <<'SQL'
SELECT count(*)
FROM "UserSession"
WHERE "userId" = :'smoke_user_id'
AND "revokedAt" IS NULL
AND "expiresAt" > now();
SQL
if [[ "$active_session_count" != "1" ]]; then
echo "Expected password login to create exactly one active server session, got ${active_session_count:-0}" >&2
emit_runtime_logs "$CONTAINER_NAME"
exit 1
fi
}
run_migrator() {
local output_variable="$1"
local stage="$2"
docker_smoke_capture "$output_variable" "$stage" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" \
docker run --rm --name "$MIGRATION_CONTAINER_NAME" --network "$NETWORK_NAME" \
-e DIRECT_URL="$MIGRATOR_DATABASE_URL" \
"$IMAGE" migrate
}
expect_migrator_completion() {
local logs="$1"
if ! grep -q '^\[migrator\] prisma migrate deploy' <<<"$logs"; then
echo "One-shot migrator logs are missing the migration banner" >&2
exit 1
fi
if ! grep -q '^\[migrator\] sync permission catalog' <<<"$logs"; then
echo "One-shot migrator logs are missing the permission catalog sync banner" >&2
exit 1
fi
if ! grep -q '"event":"permission-catalog-sync-complete"' <<<"$logs"; then
echo "One-shot migrator logs are missing the completed permission catalog postcondition" >&2
exit 1
fi
}
expect_permission_catalog_contract() {
local contract_status
docker_smoke_capture contract_status "web:permission-catalog-contract" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \
-U "$DB_USER" -d "$DB_NAME" <<'SQL'
WITH catalog_tables AS (
SELECT
relation.relname,
pg_get_userbyid(relation.relowner) AS owner_name,
relation.relrowsecurity AS rls_enabled,
relation.relforcerowsecurity AS force_rls,
has_table_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'SELECT') AS can_select,
has_table_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS has_table_dml,
has_any_column_privilege('hivegrid_rls_definer', format('%I.%I', namespace.nspname, relation.relname), 'INSERT,UPDATE,REFERENCES') AS has_column_dml
FROM pg_class AS relation
JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
WHERE namespace.nspname = 'public'
AND relation.relname IN ('Permission', 'Role', 'RolePermission')
), canonical_roles AS (
SELECT *
FROM "Role"
WHERE "tenantId" IS NULL
AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User')
)
SELECT (
(SELECT count(*) = 3 AND bool_and(owner_name = 'hivegrid_migrator' AND rls_enabled AND force_rls AND can_select AND NOT has_table_dml AND NOT has_column_dml) FROM catalog_tables)
AND (SELECT count(*) = 5 AND bool_and(NOT "isTemplateOrigin") FROM canonical_roles)
AND EXISTS (
SELECT 1 FROM canonical_roles
WHERE "name" = 'SuperUser'
AND "description" = 'Has all app permissions'
AND "type" = 'app'
AND NOT "assignToNewUsers"
AND "isDefault"
AND "order" = 2
)
AND EXISTS (
SELECT 1 FROM "Permission"
WHERE "name" = 'chat.write'
AND "description" = 'Default permission chat.write'
AND "type" = 'app'
AND "isDefault"
AND "order" > 0
)
AND EXISTS (
SELECT 1
FROM "RolePermission" AS grant_row
JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId"
JOIN "Permission" AS permission_row ON permission_row."id" = grant_row."permissionId"
WHERE role_row."tenantId" IS NULL
AND role_row."name" = 'SuperUser'
AND permission_row."name" = 'chat.write'
)
AND NOT EXISTS (
SELECT 1
FROM "RolePermission" AS grant_row
JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId"
JOIN "Permission" AS permission_row ON permission_row."id" = grant_row."permissionId"
WHERE role_row."tenantId" IS NULL
AND role_row."name" = 'User'
AND permission_row."name" = 'admin.accounts.view'
)
)::text;
SQL
if [[ "$contract_status" != "true" ]]; then
echo "Permission catalog data, FORCE RLS, or definer ACL postcondition failed" >&2
exit 1
fi
}
seed_permission_catalog_sentinel() {
docker_smoke_run "web:seed-permission-sentinel" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \
-U "$DB_USER" -d "$DB_NAME" \
-v sentinel_tenant_id="$CATALOG_SENTINEL_TENANT_ID" \
-v sentinel_role_id="$CATALOG_SENTINEL_ROLE_ID" \
-v sentinel_grant_id="$CATALOG_SENTINEL_GRANT_ID" >/dev/null <<'SQL'
INSERT INTO "Tenant" ("id", "updatedAt", "slug", "name", "active")
VALUES (:'sentinel_tenant_id', now(), 'docker-catalog-sentinel', 'Docker catalog sentinel', true);
INSERT INTO "Role" (
"id", "updatedAt", "tenantId", "name", "description", "type",
"assignToNewUsers", "isDefault", "order", "seatCategory", "isTemplateOrigin"
)
VALUES (
:'sentinel_role_id', now(), :'sentinel_tenant_id', 'Admin', 'Tenant-owned byte sentinel', 'app',
true, true, 777, 'office', true
);
INSERT INTO "RolePermission" ("id", "roleId", "permissionId")
SELECT :'sentinel_grant_id', :'sentinel_role_id', "id"
FROM "Permission"
WHERE "name" = 'app.dashboard.view';
SQL
}
permission_catalog_state_snapshot() {
local snapshot
docker_smoke_capture snapshot "web:permission-state-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \
-U "$DB_USER" -d "$DB_NAME" <<'SQL'
SELECT md5(
COALESCE((
SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "isDefault"::text, "order"::text, "updatedAt"::text), E'\n' ORDER BY "name")
FROM "Permission"
WHERE "isDefault"
), '') || E'\n--roles--\n' || COALESCE((
SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "assignToNewUsers"::text, "isDefault"::text, "order"::text, "isTemplateOrigin"::text, "updatedAt"::text), E'\n' ORDER BY "name")
FROM "Role"
WHERE "tenantId" IS NULL
AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User')
), '') || E'\n--grants--\n' || COALESCE((
SELECT string_agg(concat_ws('|', grant_row."id", grant_row."roleId", grant_row."permissionId"), E'\n' ORDER BY grant_row."roleId", grant_row."permissionId")
FROM "RolePermission" AS grant_row
JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId"
WHERE role_row."tenantId" IS NULL
AND role_row."name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User')
), '')
);
SQL
printf '%s' "$snapshot"
}
permission_catalog_value_snapshot() {
local snapshot
docker_smoke_capture snapshot "web:permission-value-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \
-U "$DB_USER" -d "$DB_NAME" <<'SQL'
SELECT md5(
COALESCE((
SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "isDefault"::text, "order"::text), E'\n' ORDER BY "name")
FROM "Permission"
WHERE "isDefault"
), '') || E'\n--roles--\n' || COALESCE((
SELECT string_agg(concat_ws('|', "id", "name", "description", "type", "assignToNewUsers"::text, "isDefault"::text, "order"::text, "isTemplateOrigin"::text), E'\n' ORDER BY "name")
FROM "Role"
WHERE "tenantId" IS NULL
AND "name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User')
), '') || E'\n--grants--\n' || COALESCE((
SELECT string_agg(concat_ws('|', grant_row."id", grant_row."roleId", grant_row."permissionId"), E'\n' ORDER BY grant_row."roleId", grant_row."permissionId")
FROM "RolePermission" AS grant_row
JOIN "Role" AS role_row ON role_row."id" = grant_row."roleId"
WHERE role_row."tenantId" IS NULL
AND role_row."name" IN ('SuperAdmin', 'SuperUser', 'Admin', 'Billing Admin', 'User')
), '')
);
SQL
printf '%s' "$snapshot"
}
tenant_catalog_sentinel_snapshot() {
local snapshot
docker_smoke_capture snapshot "web:tenant-sentinel-snapshot" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -qAt \
-U "$DB_USER" -d "$DB_NAME" \
-v sentinel_role_id="$CATALOG_SENTINEL_ROLE_ID" <<'SQL'
SELECT md5(concat_ws('|',
role_row."id", role_row."updatedAt"::text, role_row."tenantId", role_row."name", role_row."description", role_row."type",
role_row."assignToNewUsers"::text, role_row."isDefault"::text, role_row."order"::text,
role_row."seatCategory"::text, role_row."isTemplateOrigin"::text,
grant_row."id", grant_row."permissionId"
))
FROM "Role" AS role_row
JOIN "RolePermission" AS grant_row ON grant_row."roleId" = role_row."id"
WHERE role_row."id" = :'sentinel_role_id';
SQL
printf '%s' "$snapshot"
}
inject_permission_catalog_drift() {
docker_smoke_run "web:inject-permission-drift" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker exec -i "$DB_CONTAINER_NAME" psql -v ON_ERROR_STOP=1 -q \
-U "$DB_USER" -d "$DB_NAME" \
-v stale_grant_id="$CATALOG_STALE_GRANT_ID" >/dev/null <<'SQL'
UPDATE "Permission"
SET "description" = 'drifted', "type" = 'drifted', "isDefault" = false, "order" = -1
WHERE "name" = 'chat.write';
UPDATE "Role"
SET "description" = 'drifted', "type" = 'admin', "assignToNewUsers" = true, "isDefault" = false, "order" = -1
WHERE "tenantId" IS NULL AND "name" = 'SuperUser';
INSERT INTO "RolePermission" ("id", "roleId", "permissionId")
SELECT :'stale_grant_id', role_row."id", permission_row."id"
FROM "Role" AS role_row
CROSS JOIN "Permission" AS permission_row
WHERE role_row."tenantId" IS NULL
AND role_row."name" = 'User'
AND permission_row."name" = 'admin.accounts.view'
ON CONFLICT ("roleId", "permissionId") DO NOTHING;
SQL
}
docker_smoke_install_cleanup_traps cleanup
cleanup
SMOKE_REVISION="$(resolve_smoke_revision)"
if [[ -z "$SMOKE_REVISION" || "$SMOKE_REVISION" == "<no value>" || "$SMOKE_REVISION" == "unknown" ]]; then
echo "Image ${IMAGE} does not expose a concrete org.opencontainers.image.revision; build with --build-arg APP_REVISION or set DOCKER_WEB_SMOKE_REVISION explicitly" >&2
exit 1
fi
docker_smoke_run "web:network-create" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker network create "$NETWORK_NAME" >/dev/null
docker_smoke_run "web:redis-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run -d --name "$REDIS_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias redis redis:7-alpine >/dev/null
for attempt in $(seq 1 30); do
if docker_smoke_probe "web:redis-ready" docker exec "$REDIS_CONTAINER_NAME" redis-cli ping 2>/dev/null | grep -q PONG; then
break
fi
if [[ "$attempt" == "30" ]]; then
echo "Redis smoke service did not become ready" >&2
emit_runtime_logs "$REDIS_CONTAINER_NAME"
exit 1
fi
sleep 1
done
docker_smoke_run "web:postgres-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db -p 127.0.0.1::5432 \
-e POSTGRES_USER="$DB_USER" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
postgres:16-alpine >/dev/null
MIGRATOR_DATABASE_URL="postgresql://hivegrid_migrator:${MIGRATOR_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
APP_DATABASE_URL="postgresql://hivegrid_app:${APP_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
# Probe over TCP, not the unix socket. On first boot the postgres image starts a
# temporary server that listens on the socket ONLY (initdb + init scripts), then
# shuts it down before starting the real one. A socket-based pg_isready reports
# ready during that window, so the very next psql can hit
# "FATAL: the database system is shutting down". The temp server sets
# listen_addresses='', so a TCP probe cannot succeed too early.
if ! docker_smoke_wait_for_postgres "web:postgres-ready" \
"$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME"; then
echo "Postgres smoke database did not become ready within the ${DOCKER_SMOKE_DATABASE_READY_TIMEOUT_SECONDS}-second budget" >&2
emit_runtime_logs "$DB_CONTAINER_NAME"
exit 1
fi
provision_docker_smoke_rls_roles \
"web:provision-rls-roles" \
"$DB_CONTAINER_NAME" "$DB_USER" "$DB_NAME" \
"$MIGRATOR_DB_PASSWORD" "$APP_DB_PASSWORD" "$WORKER_DB_PASSWORD" "$AUTHENTIK_JIT_DB_PASSWORD"
MIGRATOR_LOGS=""
run_migrator MIGRATOR_LOGS "web:migrate:initial"
expect_migrator_completion "$MIGRATOR_LOGS"
expect_permission_catalog_contract
seed_permission_catalog_sentinel
TENANT_SENTINEL_SNAPSHOT="$(tenant_catalog_sentinel_snapshot)"
CATALOG_STATE_SNAPSHOT="$(permission_catalog_state_snapshot)"
CATALOG_VALUE_SNAPSHOT="$(permission_catalog_value_snapshot)"
if [[ -z "$TENANT_SENTINEL_SNAPSHOT" || -z "$CATALOG_STATE_SNAPSHOT" || -z "$CATALOG_VALUE_SNAPSHOT" ]]; then
echo "Permission catalog smoke snapshots are incomplete" >&2
exit 1
fi
SECOND_MIGRATOR_LOGS=""
run_migrator SECOND_MIGRATOR_LOGS "web:migrate:idempotency"
expect_migrator_completion "$SECOND_MIGRATOR_LOGS"
expect_permission_catalog_contract
if [[ "$(tenant_catalog_sentinel_snapshot)" != "$TENANT_SENTINEL_SNAPSHOT" ]]; then
echo "Idempotent permission catalog sync mutated a tenant-owned template role" >&2
exit 1
fi
if [[ "$(permission_catalog_state_snapshot)" != "$CATALOG_STATE_SNAPSHOT" ]]; then
echo "Idempotent permission catalog sync changed canonical catalog state" >&2
exit 1
fi
inject_permission_catalog_drift
THIRD_MIGRATOR_LOGS=""
run_migrator THIRD_MIGRATOR_LOGS "web:migrate:drift-repair"
expect_migrator_completion "$THIRD_MIGRATOR_LOGS"
expect_permission_catalog_contract
if [[ "$(tenant_catalog_sentinel_snapshot)" != "$TENANT_SENTINEL_SNAPSHOT" ]]; then
echo "Permission catalog drift repair mutated a tenant-owned template role" >&2
exit 1
fi
if [[ "$(permission_catalog_value_snapshot)" != "$CATALOG_VALUE_SNAPSHOT" ]]; then
echo "Permission catalog drift repair did not restore canonical values and grants" >&2
exit 1
fi
# The feature branch intentionally carries its Prisma and RLS artifacts before
# the reviewed merge migration exists. Apply those artifacts only to this
# disposable, already-migrated smoke database so both runtime surfaces exercise
# the current built-image schema without weakening the production migration
# contract.
apply_image_sql_artifact "/myapp/prisma/rls/portal-legacy-commerce-recut-pre-db-push.sql" "web:sql-artifact:legacy-recut"
docker_smoke_run "web:db-push" "$DOCKER_SMOKE_MIGRATION_TIMEOUT_SECONDS" \
docker run --rm --name "$DB_PUSH_CONTAINER_NAME" --network "$NETWORK_NAME" --entrypoint node \
-e DATABASE_URL="$MIGRATOR_DATABASE_URL" \
"$IMAGE" /myapp/node_modules/prisma/build/index.js db push \
--accept-data-loss \
--config prisma.config.ts
apply_image_sql_artifact "/myapp/prisma/rls/tenant-policy-contract-isolation.sql" "web:sql-artifact:tenant-policy"
apply_image_sql_artifact "/myapp/prisma/rls/portal-policy-contract-isolation.sql" "web:sql-artifact:portal-policy"
apply_image_sql_artifact "/myapp/prisma/rls/portal-domain-capabilities.sql" "web:sql-artifact:portal-domain"
seed_password_login_fixture
publish_args=(-p 127.0.0.1::8080)
if [[ -n "$PORT" ]]; then
publish_args=(-p "${PORT}:8080")
fi
docker_smoke_run "web:core-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" "${publish_args[@]}" \
-e NODE_ENV=production \
-e PORT=8080 \
-e APP_NAME=HiveGrid \
-e APP_BASE_DOMAIN=localhost \
-e WEB_APP_HOST=app.localhost \
-e ADMIN_APP_HOST=admin.localhost \
-e API_HOST=api.localhost \
-e PORTAL_APP_HOST=portal.localhost \
-e APP_PROTOCOL=http \
-e WEB_RUNTIME_SURFACE=core \
-e DATABASE_URL="$APP_DATABASE_URL" \
-e DATABASE_RUNTIME_ROLE=hivegrid_app \
-e RLS_RUNTIME_MODE=required \
-e SENTRY_ENVIRONMENT=staging \
-e STAGING_DEMO_LOGIN_ENABLED=true \
-e REDIS_URL='redis://redis:6379/0' \
-e IP_HASH_PEPPER='deploy-smoke-ip-hash-pepper-minimum-32' \
-e AUTH_PASSWORD_HIBP_CHECK=false \
-e SESSION_SECRET='deploy-smoke-session-secret-minimum-32' \
-e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \
-e ADMIN_CONNECTION_MASTER_KEY='deploy-smoke-admin-connection-key-minimum-32' \
-e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \
-e AUDIT_CHAIN_SECRET='deploy-smoke-audit-chain-secret-minimum-32' \
-e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \
-e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \
-e WEB_PUSH_VAPID_PUBLIC_KEY="$SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY" \
-e STORAGE_ENDPOINT='https://storage.example.test' \
-e STORAGE_REGION='eu-central-1' \
-e STORAGE_BUCKET='docker-smoke-private-exports' \
-e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \
-e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \
-e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \
-e STORAGE_FORCE_PATH_STYLE=false \
-e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \
-e MCP_RESOURCE_URL='http://mcp.localhost:8080' \
-e MCP_AUTHORIZATION_SERVER_URL='http://app.localhost' \
"$IMAGE" >/dev/null
if [[ -z "$PORT" ]]; then
CORE_PORT_BINDING=""
docker_smoke_capture CORE_PORT_BINDING "web:core-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
docker port "$CONTAINER_NAME" 8080/tcp
PORT="$(printf '%s\n' "$CORE_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')"
fi
wait_for_runtime_health app.localhost Web
assert_runtime_boot_is_mutation_free Web
expect_status app.localhost /healthz 200
expect_health_revision app.localhost /healthz
expect_status app.localhost /readyz 200
expect_password_login_session
expect_status app.localhost /sw.js 200
expect_service_worker_csp app.localhost
expect_service_worker_recovery_bootstrap app.localhost
# The web image must never serve the separately deployed API surface.
expect_status api.localhost /v1/docs 404
expect_status api.localhost /v1/openapi.json 404
expect_status api.localhost /v1/health 404
expect_status app.localhost /app/acme/settings 404
expect_status admin.localhost /settings 404
docker_smoke_run "web:core-cleanup" "$DOCKER_SMOKE_CLEANUP_TIMEOUT_SECONDS" \
docker rm -f "$CORE_CONTAINER_NAME" >/dev/null
CONTAINER_NAME="$ADMIN_CONTAINER_NAME"
PORT="$ADMIN_PORT"
admin_publish_args=(-p 127.0.0.1::8080)
if [[ -n "$PORT" ]]; then
admin_publish_args=(-p "${PORT}:8080")
fi
docker_smoke_run "web:admin-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run -d --name "$ADMIN_CONTAINER_NAME" --network "$NETWORK_NAME" "${admin_publish_args[@]}" \
-e NODE_ENV=production \
-e PORT=8080 \
-e APP_NAME=HiveGrid \
-e APP_BASE_DOMAIN=localhost \
-e WEB_APP_HOST=app.localhost \
-e ADMIN_APP_HOST=admin.localhost \
-e API_HOST=api.localhost \
-e PORTAL_APP_HOST=portal.localhost \
-e APP_PROTOCOL=http \
-e WEB_RUNTIME_SURFACE=admin \
-e DATABASE_URL="$APP_DATABASE_URL" \
-e DATABASE_RUNTIME_ROLE=hivegrid_app \
-e RLS_RUNTIME_MODE=required \
-e SENTRY_ENVIRONMENT=staging \
-e REDIS_URL='redis://redis:6379/0' \
-e IP_HASH_PEPPER='deploy-smoke-ip-hash-pepper-minimum-32' \
-e AUTH_PASSWORD_HIBP_CHECK=false \
-e SESSION_SECRET='deploy-smoke-admin-session-secret-minimum-32' \
-e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \
-e ADMIN_CONNECTION_MASTER_KEY='deploy-smoke-admin-connection-key-minimum-32' \
-e ADMIN_FALLBACK_LOGIN_ENABLED=false \
-e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \
-e AUDIT_CHAIN_SECRET='deploy-smoke-audit-chain-secret-minimum-32' \
-e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \
-e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \
-e WEB_PUSH_VAPID_PUBLIC_KEY="$SMOKE_WEB_PUSH_VAPID_PUBLIC_KEY" \
-e STORAGE_ENDPOINT='https://storage.example.test' \
-e STORAGE_REGION='eu-central-1' \
-e STORAGE_BUCKET='docker-smoke-private-exports' \
-e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \
-e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \
-e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \
-e STORAGE_FORCE_PATH_STYLE=false \
-e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \
-e MCP_RESOURCE_URL='http://mcp.localhost:8080' \
-e MCP_AUTHORIZATION_SERVER_URL='http://admin.localhost' \
-e AUTHENTIK_ISSUER_URL='https://auth.example.test/application/o/hivegrid-admin/' \
-e AUTHENTIK_ADMIN_CLIENT_ID='docker-smoke-admin' \
-e AUTHENTIK_ADMIN_CLIENT_SECRET='docker-smoke-authentik-secret-minimum-32' \
-e AUTHENTIK_ADMIN_REDIRECT_URI='https://admin.localhost/auth/admin/callback' \
-e AUTHENTIK_ADMIN_JIT_DATABASE_URL="postgresql://hivegrid_authentik_jit:${AUTHENTIK_JIT_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public" \
"$IMAGE" >/dev/null
if [[ -z "$PORT" ]]; then
ADMIN_PORT_BINDING=""
docker_smoke_capture ADMIN_PORT_BINDING "web:admin-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
docker port "$ADMIN_CONTAINER_NAME" 8080/tcp
PORT="$(printf '%s\n' "$ADMIN_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')"
fi
wait_for_runtime_health admin.localhost Admin
assert_runtime_boot_is_mutation_free Admin
expect_status admin.localhost /healthz 200
expect_runtime_payload admin.localhost /healthz ok hivegrid-admin admin
expect_status admin.localhost /readyz 200
expect_runtime_payload admin.localhost /readyz ready hivegrid-admin admin
expect_status app.localhost /healthz 404
expect_status portal.localhost /healthz 404
expect_status admin.localhost /settings 302
docker_smoke_run "web:admin-cleanup" "$DOCKER_SMOKE_CLEANUP_TIMEOUT_SECONDS" \
docker rm -f "$ADMIN_CONTAINER_NAME" >/dev/null
PORTAL_DATABASE_URL="postgresql://hivegrid_portal:${DOCKER_SMOKE_PORTAL_DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
CONTAINER_NAME="$PORTAL_CONTAINER_NAME"
PORT="$PORTAL_PORT"
portal_publish_args=(-p 127.0.0.1::8080)
if [[ -n "$PORT" ]]; then
portal_publish_args=(-p "${PORT}:8080")
fi
docker_smoke_run "web:portal-start" "$DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS" \
docker run -d --name "$PORTAL_CONTAINER_NAME" --network "$NETWORK_NAME" "${portal_publish_args[@]}" \
-e NODE_ENV=production \
-e PORT=8080 \
-e APP_NAME=HiveGrid \
-e APP_BASE_DOMAIN=localhost \
-e PORTAL_APP_HOST=portal.localhost \
-e APP_PROTOCOL=http \
-e WEB_RUNTIME_SURFACE=portal \
-e DATABASE_URL="$PORTAL_DATABASE_URL" \
-e DATABASE_RUNTIME_ROLE=hivegrid_portal \
-e RLS_RUNTIME_MODE=required \
-e SENTRY_ENVIRONMENT=staging \
-e PORTAL_SESSION_SECRET='portal-smoke-session-secret-minimum-32' \
-e AUTH_MAGIC_LINK_ENABLED=true \
-e AUTH_MAGIC_LINK_TOKEN_TTL_MINUTES=15 \
-e CAPTCHA_PROVIDER=mcaptcha \
-e MCAPTCHA_INSTANCE_URL='http://127.0.0.1:9090' \
-e MCAPTCHA_SITEKEY_LOGIN='portal-smoke-login-sitekey' \
-e MCAPTCHA_ACCOUNT_SECRET='portal-smoke-captcha-secret' \
-e REDIS_URL='redis://redis:6379/0' \
-e BULLMQ_REDIS_URL='redis://redis:6379/0' \
-e STORAGE_ENDPOINT='https://storage.example.test' \
-e STORAGE_REGION='eu-central-1' \
-e STORAGE_BUCKET='docker-smoke-private-exports' \
-e STORAGE_ACCESS_KEY_ID='docker-smoke-storage-access-key' \
-e STORAGE_SECRET_ACCESS_KEY='docker-smoke-storage-secret-key' \
-e STORAGE_SESSION_TOKEN='docker-smoke-storage-session-token' \
-e STORAGE_FORCE_PATH_STYLE=false \
-e GDPR_EXPORT_SSE_C_KEY_BASE64="$SMOKE_GDPR_EXPORT_SSE_C_KEY_BASE64" \
-e MCP_RESOURCE_URL='http://mcp.localhost:8080' \
-e MCP_AUTHORIZATION_SERVER_URL='http://portal.localhost' \
"$IMAGE" >/dev/null
if [[ -z "$PORT" ]]; then
PORTAL_PORT_BINDING=""
docker_smoke_capture PORTAL_PORT_BINDING "web:portal-port" "$DOCKER_SMOKE_PROBE_TIMEOUT_SECONDS" \
docker port "$PORTAL_CONTAINER_NAME" 8080/tcp
PORT="$(printf '%s\n' "$PORTAL_PORT_BINDING" | awk -F: 'NR == 1 { print $NF }')"
fi
wait_for_runtime_health portal.localhost Portal
assert_runtime_boot_is_mutation_free Portal
expect_status portal.localhost /healthz 200
expect_runtime_payload portal.localhost /healthz ok hivegrid-portal portal
expect_status portal.localhost /readyz 200
expect_runtime_payload portal.localhost /readyz ready hivegrid-portal portal
echo "Docker web, admin, and portal runtime smoke passed for ${IMAGE}"