From ef5e9fe0b8c5406e626975b0a503c9bdf07f160c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:10:54 +0000 Subject: [PATCH 1/3] Add gateway E2E smoke test + CI workflow --- .github/workflows/ci.yml | 45 ++++++++++++ scripts/e2e-smoke.sh | 151 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/e2e-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e055362 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + name: Build (Release) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Build gateway + all services (Release) + working-directory: src + run: | + set -euo pipefail + for proj in \ + ApiGateway/ApiGateway.csproj \ + Services/Identity/Identity.API/Identity.API.csproj \ + Services/Customer/Customer.API/Customer.API.csproj \ + Services/Order/Order.API/Order.API.csproj \ + Services/Product/Product.API/Product.API.csproj \ + Services/Notification/Notification.API/Notification.API.csproj; do + echo "::group::build $proj" + dotnet build "$proj" -c Release --nologo + echo "::endgroup::" + done + + e2e: + name: Full-stack E2E smoke (gateway) + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run E2E smoke test + run: ./scripts/e2e-smoke.sh diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh new file mode 100755 index 0000000..8bf2dbe --- /dev/null +++ b/scripts/e2e-smoke.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Full-stack cross-service E2E smoke test for QuickApp microservices. +# +# Brings up the entire docker compose stack (postgres, rabbitmq, the 5 services +# and the YARP API gateway), waits for readiness, then asserts end-to-end that: +# * every service answers health 200 through the gateway +# * every protected resource returns 401 without a token through the gateway +# * Identity issues a JWT via the password grant through the gateway +# * that JWT is accepted (200) by Customer, Product and Order through the gateway +# * a tampered/invalid token is rejected (401) +# +# The stack is always torn down on exit. The script exits non-zero on any failure +# and is safe to re-run (idempotent: it tears down any prior stack first). +set -euo pipefail + +# --- configuration ---------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${REPO_ROOT}/src/docker-compose.yml" +GATEWAY="${GATEWAY_URL:-http://localhost:5000}" + +# Demo credentials (dev-only; see canonical JWT contract). +LOGIN_USER="${LOGIN_USER:-admin@quickapp.local}" +LOGIN_PASS="${LOGIN_PASS:-Pa\$\$w0rd!}" + +# Services exposed through the gateway: +SERVICES=(identity customers orders products notifications) +# Services that must accept the cross-service JWT. +AUTHED_SERVICES=(customers products orders) + +READY_TIMEOUT="${READY_TIMEOUT:-180}" + +# --- helpers ---------------------------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +FAILURES=0 + +log() { printf '%b\n' "$*"; } +pass() { log "${GREEN}PASS${NC} $*"; } +fail() { log "${RED}FAIL${NC} $*"; FAILURES=$((FAILURES + 1)); } +info() { log "${YELLOW}==>${NC} $*"; } + +compose() { docker compose -f "${COMPOSE_FILE}" "$@"; } + +http_code() { + # http_code [auth-header-value] + local url="$1"; shift || true + if [[ $# -gt 0 && -n "$1" ]]; then + curl -s -o /dev/null -w '%{http_code}' -H "Authorization: $1" "${url}" + else + curl -s -o /dev/null -w '%{http_code}' "${url}" + fi +} + +assert_code() { + # assert_code + local desc="$1" expected="$2" actual="$3" + if [[ "${actual}" == "${expected}" ]]; then + pass "${desc} (${actual})" + else + fail "${desc} — expected ${expected}, got ${actual}" + fi +} + +cleanup() { + info "Tearing down stack" + compose down -v --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# --- bring up --------------------------------------------------------------- +info "Tearing down any existing stack (idempotency)" +compose down -v --remove-orphans >/dev/null 2>&1 || true + +info "Building and starting full stack" +compose up --build -d + +info "Waiting for gateway + all services to be ready (timeout ${READY_TIMEOUT}s)" +deadline=$(( $(date +%s) + READY_TIMEOUT )) +until [[ "$(http_code "${GATEWAY}/api/identity/healthz")" == "200" ]]; do + if [[ $(date +%s) -ge ${deadline} ]]; then + fail "Gateway did not become ready within ${READY_TIMEOUT}s" + compose ps + compose logs --tail=50 || true + exit 1 + fi + sleep 3 +done +# Give the remaining services a brief moment to finish migrations/startup. +for s in "${SERVICES[@]}"; do + until [[ "$(http_code "${GATEWAY}/api/${s}/healthz")" == "200" ]]; do + if [[ $(date +%s) -ge ${deadline} ]]; then break; fi + sleep 2 + done +done + +# --- assertions: health 200 ------------------------------------------------- +info "Asserting per-service health (200) through the gateway" +for s in "${SERVICES[@]}"; do + assert_code "health ${s}" 200 "$(http_code "${GATEWAY}/api/${s}/healthz")" +done + +# --- assertions: unauth 401 ------------------------------------------------- +info "Asserting per-service unauthenticated access (401) through the gateway" +for s in "${SERVICES[@]}"; do + assert_code "unauth ${s}" 401 "$(http_code "${GATEWAY}/api/${s}")" +done + +# --- login: obtain JWT ------------------------------------------------------ +info "Logging in via Identity (password grant) through the gateway" +LOGIN_RESPONSE="$(curl -s -w '\n%{http_code}' -X POST "${GATEWAY}/api/identity/connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=password' \ + --data-urlencode "username=${LOGIN_USER}" \ + --data-urlencode "password=${LOGIN_PASS}")" +LOGIN_STATUS="$(printf '%s' "${LOGIN_RESPONSE}" | tail -n1)" +LOGIN_BODY="$(printf '%s' "${LOGIN_RESPONSE}" | sed '$d')" +assert_code "identity login" 200 "${LOGIN_STATUS}" + +TOKEN="$(printf '%s' "${LOGIN_BODY}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" +if [[ -n "${TOKEN}" ]]; then + pass "received access_token" +else + fail "no access_token in login response: ${LOGIN_BODY}" +fi + +# --- assertions: cross-service authed 200 ----------------------------------- +info "Asserting cross-service authenticated access (200) through the gateway" +for s in "${AUTHED_SERVICES[@]}"; do + assert_code "authed ${s}" 200 "$(http_code "${GATEWAY}/api/${s}" "Bearer ${TOKEN}")" +done + +# --- assertions: invalid token 401 ------------------------------------------ +info "Asserting tampered/invalid token rejected (401) through the gateway" +INVALID_TOKEN="${TOKEN%?}X" # corrupt the signature +[[ "${INVALID_TOKEN}" == "${TOKEN}" || -z "${TOKEN}" ]] && INVALID_TOKEN="not.a.real.token" +for s in "${AUTHED_SERVICES[@]}"; do + assert_code "invalid-token ${s}" 401 "$(http_code "${GATEWAY}/api/${s}" "Bearer ${INVALID_TOKEN}")" +done + +# --- result ----------------------------------------------------------------- +echo +if [[ ${FAILURES} -eq 0 ]]; then + log "${GREEN}E2E SMOKE PASSED${NC} — all assertions succeeded." + exit 0 +else + log "${RED}E2E SMOKE FAILED${NC} — ${FAILURES} assertion(s) failed." + compose ps + compose logs --tail=80 || true + exit 1 +fi From 36268abfa96f8880f9f85c030fb8cdea11af8d13 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:11:22 +0000 Subject: [PATCH 2/3] CI: point setup-dotnet at src/global.json --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e055362..5335152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - global-json-file: global.json + global-json-file: src/global.json - name: Build gateway + all services (Release) working-directory: src From 3bfca1f931aef399949cfa40a87bb273c822edba Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:18:56 +0000 Subject: [PATCH 3/3] Fix cold-start race: gate services on postgres/rabbitmq healthchecks + restart on-failure; dedupe CI runs --- .github/workflows/ci.yml | 5 +++++ src/docker-compose.yml | 45 +++++++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5335152..6db9589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,13 @@ name: CI on: push: + branches: [main, master] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: name: Build (Release) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index bf66a7b..487fd10 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -22,9 +22,12 @@ services: dockerfile: Services/Identity/Identity.API/Dockerfile ports: - "5001:5001" + restart: on-failure depends_on: - - postgres - - rabbitmq + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Host=postgres;Database=identitydb;Username=postgres;Password=postgres @@ -35,9 +38,12 @@ services: dockerfile: Services/Customer/Customer.API/Dockerfile ports: - "5002:5002" + restart: on-failure depends_on: - - postgres - - rabbitmq + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Host=postgres;Database=customerdb;Username=postgres;Password=postgres @@ -48,9 +54,12 @@ services: dockerfile: Services/Order/Order.API/Dockerfile ports: - "5003:5003" + restart: on-failure depends_on: - - postgres - - rabbitmq + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Host=postgres;Database=orderdb;Username=postgres;Password=postgres @@ -61,9 +70,12 @@ services: dockerfile: Services/Product/Product.API/Dockerfile ports: - "5004:5004" + restart: on-failure depends_on: - - postgres - - rabbitmq + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Host=postgres;Database=productdb;Username=postgres;Password=postgres @@ -74,9 +86,12 @@ services: dockerfile: Services/Notification/Notification.API/Dockerfile ports: - "5005:5005" + restart: on-failure depends_on: - - postgres - - rabbitmq + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Host=postgres;Database=notificationdb;Username=postgres;Password=postgres @@ -89,6 +104,11 @@ services: POSTGRES_PASSWORD: postgres volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 12 rabbitmq: image: rabbitmq:3-management-alpine @@ -98,6 +118,11 @@ services: environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 10s + retries: 12 volumes: postgres_data: