#!/usr/bin/env bash # Spike verification for the worktree-test-stack-isolation change. # # Bootstraps a stack with a forced non-default port slot, brings up Keycloak + # its seed, and asserts that the seeded redirectUris/webOrigins on the # member-console and temporal-ui clients reflect the templated MC_BASE_URL / # TEMPORAL_UI_URL — not the hardcoded localhost:8081 / :8233 defaults. # # This is the canary for tasks 2.6 and 2.7 in tasks.md: it proves Keycloak's # admin API actually accepts our partial PUT bodies on /clients/{id} and # stores the values, rather than silently ignoring them. # # Usage: # ./test/verify-stack-isolation.sh # uses slot 137 by default # FORCE_SLOT=42 ./test/verify-stack-isolation.sh # # Side effects: brings the stack up, then tears it down on exit. Requires # docker, jq, curl. Will refuse to run if test/.env already exists (run # teardown-stack.sh first). set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" TEST_DIR="${REPO_ROOT}/test" ENV_FILE="${TEST_DIR}/.env" FORCE_SLOT="${FORCE_SLOT:-137}" PORT_STEP=50 cleanup() { local rc=$? echo "" echo "--- Cleaning up ---" "${TEST_DIR}/teardown-stack.sh" >/dev/null 2>&1 || true exit "$rc" } trap cleanup EXIT if [[ -f "$ENV_FILE" ]]; then echo "ERROR: ${ENV_FILE} already exists. Run ./test/teardown-stack.sh first." >&2 exit 1 fi # Compute expected ports for FORCE_SLOT. KEYCLOAK_PORT=$((8080 + FORCE_SLOT * PORT_STEP)) MC_PORT=$((8081 + FORCE_SLOT * PORT_STEP)) TEMPORAL_UI_PORT=$((8233 + FORCE_SLOT * PORT_STEP)) EXPECTED_MC_BASE_URL="http://localhost:${MC_PORT}" EXPECTED_TEMPORAL_UI_URL="http://localhost:${TEMPORAL_UI_PORT}" EXPECTED_KC_HOSTNAME="keycloak-slot${FORCE_SLOT}.localhost" echo "=== Spike: forcing slot ${FORCE_SLOT} ===" echo " Keycloak port: ${KEYCLOAK_PORT}" echo " MC port: ${MC_PORT}" echo " Temporal UI port: ${TEMPORAL_UI_PORT}" # Hand-write a .env that pins the slot, bypassing bootstrap's hash so this is # deterministic regardless of working directory name. cat > "$ENV_FILE" <&2 exit 1 fi fi echo "" echo "--- Bringing up keycloak + keycloak-seed ---" docker compose -f "${TEST_DIR}/compose.yaml" --env-file "$ENV_FILE" \ up -d keycloak keycloak-seed echo "Waiting for keycloak-seed to complete..." SEED_CONTAINER=$(docker compose -f "${TEST_DIR}/compose.yaml" --env-file "$ENV_FILE" \ ps -q keycloak-seed) for i in $(seq 1 60); do STATE=$(docker inspect -f '{{.State.Status}}' "$SEED_CONTAINER" 2>/dev/null || echo "missing") EXIT_CODE=$(docker inspect -f '{{.State.ExitCode}}' "$SEED_CONTAINER" 2>/dev/null || echo "") if [[ "$STATE" == "exited" ]]; then if [[ "$EXIT_CODE" != "0" ]]; then echo "ERROR: keycloak-seed exited non-zero (${EXIT_CODE}). Logs:" >&2 docker logs "$SEED_CONTAINER" >&2 exit 1 fi echo "Seed completed." break fi if [[ "$i" -eq 60 ]]; then echo "ERROR: keycloak-seed did not finish within 120s" >&2 docker logs "$SEED_CONTAINER" >&2 || true exit 1 fi sleep 2 done echo "" echo "--- Querying Keycloak admin API for stored client config ---" KC_URL="http://localhost:${KEYCLOAK_PORT}" TOKEN=$(curl -sf "${KC_URL}/realms/master/protocol/openid-connect/token" \ -d "grant_type=password" \ -d "client_id=admin-cli" \ -d "username=admin" \ -d "password=admin" | jq -r '.access_token') if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then echo "ERROR: failed to obtain admin token from ${KC_URL}" >&2 exit 1 fi kc_get() { curl -sf -H "Authorization: Bearer ${TOKEN}" \ "${KC_URL}/admin/realms/master$1" } assert_eq() { local label="$1" expected="$2" actual="$3" if [[ "$expected" == "$actual" ]]; then echo " PASS ${label}: ${actual}" else echo " FAIL ${label}" echo " expected: ${expected}" echo " actual: ${actual}" FAILED=1 fi } FAILED=0 MC_CLIENT=$(kc_get "/clients?clientId=member-console" | jq '.[0]') MC_REDIRECT=$(echo "$MC_CLIENT" | jq -r '.redirectUris[0]') MC_WEBORIGIN=$(echo "$MC_CLIENT" | jq -r '.webOrigins[0]') MC_LOGOUT=$(echo "$MC_CLIENT" | jq -r '.attributes."post.logout.redirect.uris"') assert_eq "member-console.redirectUris[0]" "${EXPECTED_MC_BASE_URL}/*" "$MC_REDIRECT" assert_eq "member-console.webOrigins[0]" "${EXPECTED_MC_BASE_URL}" "$MC_WEBORIGIN" assert_eq "member-console.post.logout.redirect" "${EXPECTED_MC_BASE_URL}/*" "$MC_LOGOUT" TUI_CLIENT=$(kc_get "/clients?clientId=temporal-ui" | jq '.[0]') TUI_REDIRECT=$(echo "$TUI_CLIENT" | jq -r '.redirectUris[0]') TUI_WEBORIGIN=$(echo "$TUI_CLIENT" | jq -r '.webOrigins[0]') assert_eq "temporal-ui.redirectUris[0]" "${EXPECTED_TEMPORAL_UI_URL}/*" "$TUI_REDIRECT" assert_eq "temporal-ui.webOrigins[0]" "${EXPECTED_TEMPORAL_UI_URL}" "$TUI_WEBORIGIN" echo "" echo "--- Re-running seed to verify idempotent update (task 2.6) ---" docker compose -f "${TEST_DIR}/compose.yaml" --env-file "$ENV_FILE" \ run --rm keycloak-seed >/dev/null # Re-fetch and re-assert (values must still match after a second seed run). TOKEN=$(curl -sf "${KC_URL}/realms/master/protocol/openid-connect/token" \ -d "grant_type=password" -d "client_id=admin-cli" \ -d "username=admin" -d "password=admin" | jq -r '.access_token') MC_REDIRECT2=$(kc_get "/clients?clientId=member-console" | jq -r '.[0].redirectUris[0]') assert_eq "member-console.redirectUris[0] (after re-seed)" \ "${EXPECTED_MC_BASE_URL}/*" "$MC_REDIRECT2" echo "" if [[ "$FAILED" -eq 0 ]]; then echo "=== ALL ASSERTIONS PASSED ===" exit 0 else echo "=== ASSERTIONS FAILED ===" >&2 exit 1 fi