diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh new file mode 100755 index 0000000000..60a11acd28 --- /dev/null +++ b/.github/scripts/public-api-e2e-tests.sh @@ -0,0 +1,502 @@ +#!/usr/bin/env bash +# End-to-end tests for the Public API (scheduled regression suite). +# +# The suite seeds its own test data and communicates with the API over HTTP only. +# Database setup/reset is handled externally. +# +# Required environment variables (same names as GitHub Actions secrets/vars): +# AUTH0_STAGING_AUDIENCE +# AUTH0_STAGING_ISSUER +# AUTH0_API_E2E_CLIENT_ID +# AUTH0_API_E2E_CLIENT_SECRET +# CDP_API_E2E_BASE_URL +# +# Do not enable `set -x`; requests include sensitive credentials. +# +# Structure: +# - Shared helpers (api, check, require) +# - Seed data +# - One test suite per resource +# +# Each test case is an api call followed by check. Stateful suites are a short +# workflow. Register new suites in main. + +set -euo pipefail + +if [[ $- == *x* ]]; then + echo "error: refusing to run with xtrace (set -x); credentials would leak" >&2 + exit 1 +fi + +for cmd in curl jq; do + command -v "$cmd" >/dev/null || { + echo "error: '$cmd' is required" >&2 + exit 1 + } +done + +: "${AUTH0_API_E2E_CLIENT_ID:?set AUTH0_API_E2E_CLIENT_ID}" +: "${AUTH0_API_E2E_CLIENT_SECRET:?set AUTH0_API_E2E_CLIENT_SECRET}" +: "${AUTH0_STAGING_AUDIENCE:?set AUTH0_STAGING_AUDIENCE}" +: "${AUTH0_STAGING_ISSUER:?set AUTH0_STAGING_ISSUER}" +: "${CDP_API_E2E_BASE_URL:?set CDP_API_E2E_BASE_URL}" + +VERIFIED_BY="${VERIFIED_BY:-jordan.lee+e2e@example.com}" +RUN_ID="${GITHUB_RUN_ID:-$(date +%s)}-${RANDOM}" +SOURCE="lfxOne-api-e2e" + +PASS=0 +FAIL=0 +TOKEN="" +HTTP_CODE="" +BODY="" + +die() { + echo "error: $*" >&2 + exit 1 +} + +CURL_CONNECT_TIMEOUT=10 +CURL_MAX_TIME=30 + +api() { + local method=$1 path=$2 body=${3-} + local -a args=(-sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" + -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}${path}" + -H 'Content-Type: application/json' + -H "Authorization: Bearer ${TOKEN}") + [[ -n $body ]] && args+=(-d "$body") + + local response + response="$(curl "${args[@]}")" + HTTP_CODE="$(printf '%s' "$response" | tail -n1)" + BODY="$(printf '%s' "$response" | sed '$d')" +} + +# Print a short, non-secret snippet of the last res on failure (never tokens). +log_fail_body() { + local snippet + snippet="$(jq -c 'if type == "object" then del(.access_token, .refresh_token, .id_token, .client_secret) else . end' <<<"$BODY" 2>/dev/null || printf '%s' "$BODY")" + snippet="${snippet:0:500}" + printf ' body: %s\n' "$snippet" +} + +check() { + local name=$1 expected=$2 + shift 2 + if [[ $HTTP_CODE != "$expected" ]]; then + printf ' FAIL %s — expected HTTP %s, got %s\n' "$name" "$expected" "$HTTP_CODE" + log_fail_body + FAIL=$((FAIL + 1)) + return + fi + local expr + for expr in "$@"; do + if ! jq -e "$expr" >/dev/null 2>&1 <<<"$BODY"; then + printf ' FAIL %s (%s)\n' "$name" "$expr" + log_fail_body + FAIL=$((FAIL + 1)) + return + fi + done + printf ' PASS %s (HTTP %s)\n' "$name" "$HTTP_CODE" + PASS=$((PASS + 1)) +} + +require() { + local expected=$1 name=$2 + [[ $HTTP_CODE == "$expected" ]] || die "$name — expected HTTP $expected, got $HTTP_CODE" +} + +json() { + jq -n "$@" +} + +fetch_token() { + local response + response="$(curl -sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" \ + -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \ + -H 'Content-Type: application/json' \ + -d "$(json \ + --arg client_id "$AUTH0_API_E2E_CLIENT_ID" \ + --arg client_secret "$AUTH0_API_E2E_CLIENT_SECRET" \ + --arg audience "$AUTH0_STAGING_AUDIENCE" \ + '{grant_type:"client_credentials",client_id:$client_id,client_secret:$client_secret,audience:$audience}')")" + + TOKEN="$(jq -r '.access_token // empty' <<<"$response")" + [[ -n $TOKEN ]] || die "Auth0 token failed: $(jq -c '{error,error_description}' <<<"$response" 2>/dev/null || echo unavailable)" +} + +# ── fixtures ───────────────────────────────────────────────────────────────── + +ACME_NAME="Acme-${RUN_ID}" +ACME_DOMAIN="acme-${RUN_ID}.example.com" +ACME_ID="" + +GLOBEX_NAME="Globex-${RUN_ID}" +GLOBEX_DOMAIN="globex-${RUN_ID}.example.com" +GLOBEX_ID="" + +PERSON_NAME="Jordan Lee" +PERSON_LFID="jordan.lee-${RUN_ID}" +PERSON_ID="" + +seed() { + echo "=== seed (${RUN_ID}) ===" + + api POST /organizations "$(json \ + --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \ + '{name:$name, domain:$domain, source:$source}')" + require 201 "create Acme" + ACME_ID="$(jq -r '.id' <<<"$BODY")" + + api POST /organizations "$(json \ + --arg name "$GLOBEX_NAME" --arg domain "$GLOBEX_DOMAIN" --arg source "$SOURCE" \ + '{name:$name, domain:$domain, source:$source}')" + require 201 "create Globex" + GLOBEX_ID="$(jq -r '.id' <<<"$BODY")" + + api POST /members "$(json \ + --arg lfid "$PERSON_LFID" --arg by "$VERIFIED_BY" --arg name "$PERSON_NAME" '{ + displayName: $name, + identities: [{ + value: $lfid, + platform: "lfid", + type: "username", + source: "lfxOne", + verified: true, + verifiedBy: $by + }] + }')" + require 201 "create Jordan" + PERSON_ID="$(jq -r '.memberId' <<<"$BODY")" + [[ -n $PERSON_ID && $PERSON_ID != null ]] || die "create Jordan returned no memberId" + + echo " acme=$ACME_ID" + echo " globex=$GLOBEX_ID" + echo " person=$PERSON_ID" +} + +# ── /organizations ─────────────────────────────────────────────────────────── + +suite_organizations() { + echo + echo "=== /organizations ===" + + local name_q + name_q="$(jq -nr --arg n "$ACME_NAME" '$n|@uri')" + + api GET "/organizations?domain=${ACME_DOMAIN}" + check "GET find Acme by domain" 200 \ + ".id == \"$ACME_ID\"" \ + ".name == \"$ACME_NAME\"" + + api GET "/organizations?name=${name_q}" + check "GET find Acme by name" 200 ".id == \"$ACME_ID\"" + + api GET "/organizations?name=${name_q}&domain=${ACME_DOMAIN}" + check "GET find Acme by name+domain" 200 ".id == \"$ACME_ID\"" + + api GET "/organizations?name=${name_q}&domain=example.com" + check "GET name/domain mismatch" 404 + + api GET "/organizations" + check "GET missing params" 400 + + api GET "/organizations?domain=missing-${RUN_ID}.example.com" + check "GET unknown domain" 404 + + api GET "/organizations?domain=not-a-valid-domain" + check "GET invalid domain" 400 + + api POST /organizations "$(json \ + --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \ + '{name:$name, domain:$domain, source:$source}')" + check "POST recreate Acme is idempotent" 201 ".id == \"$ACME_ID\"" + + api POST /organizations "$(json \ + --arg name "Bad-${RUN_ID}" --arg source "$SOURCE" \ + '{name:$name, domain:"not-a-valid-domain", source:$source}')" + check "POST reject invalid domain" 400 +} + +# ── /members ───────────────────────────────────────────────────────────────── + +suite_members() { + echo + echo "=== /members ===" + + local sam_lfid="sam.rivera-${RUN_ID}" + local sam_id + + api POST /members "$(json \ + --arg lfid "$sam_lfid" --arg by "$VERIFIED_BY" '{ + displayName: "Sam Rivera", + identities: [{ + value: $lfid, + platform: "lfid", + type: "username", + source: "lfxOne", + verified: true, + verifiedBy: $by + }] + }')" + check "POST create Sam" 201 'has("memberId")' + sam_id="$(jq -r '.memberId' <<<"$BODY")" + + api POST /members "$(json '{displayName:"No Identities", identities:[]}')" + check "POST reject empty identities" 400 + + api POST /members/resolve "$(json --arg lfid "$PERSON_LFID" '{lfids:[$lfid]}')" + check "POST resolve Jordan by lfid" 200 ".memberId == \"$PERSON_ID\"" + + api POST /members/resolve "$(json --arg lfid "$sam_lfid" '{lfids:[$lfid]}')" + check "POST resolve Sam by lfid" 200 ".memberId == \"$sam_id\"" + + api POST /members/resolve "$(json --arg lfid "nobody-${RUN_ID}" '{lfids:[$lfid]}')" + check "POST resolve unknown lfid" 404 + + api POST /members/resolve "$(json '{lfids:[]}')" + check "POST resolve reject empty lfids" 400 +} + +# ── /members/:id/identities ────────────────────────────────────────────────── + +suite_member_identities() { + echo + echo "=== /members/:id/identities ===" + + local email="jordan.lee+${RUN_ID}@example.com" + local identity_id + + api GET "/members/${PERSON_ID}/identities" + check "GET lists seeded lfid" 200 \ + '.identities | type == "array"' \ + ".identities | map(select(.platform == \"lfid\" and .value == \"$PERSON_LFID\")) | length == 1" + + api POST "/members/${PERSON_ID}/identities" "$(json \ + --arg email "$email" --arg by "$VERIFIED_BY" '{ + value: $email, + platform: "email", + type: "email", + source: "lfxOne", + verified: false, + verifiedBy: $by + }')" + check "POST add email identity" 201 \ + ".value == \"$email\"" \ + '.verified == false' + identity_id="$(jq -r '.id' <<<"$BODY")" + + api POST "/members/${PERSON_ID}/identities" "$(json \ + --arg email "$email" '{ + value: $email, + platform: "email", + type: "email", + source: "lfxOne", + verified: false + }')" + check "POST same identity is idempotent" 200 ".id == \"$identity_id\"" + + api GET "/members/${PERSON_ID}/identities" + check "GET includes email" 200 \ + ".identities | map(select(.id == \"$identity_id\")) | length == 1" + + api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \ + "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')" + check "PATCH verify email" 200 '.verified == true' + + api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \ + "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')" + check "PATCH reject email deletes unused identity" 204 + + api GET "/members/${PERSON_ID}/identities" + check "GET email gone after reject" 200 \ + ".identities | map(select(.id == \"$identity_id\")) | length == 0" +} + +# ── /members/:id/work-experiences ──────────────────────────────────────────── + +suite_member_work_experiences() { + echo + echo "=== /members/:id/work-experiences ===" + + local acme_we globex_we doomed_we + + api POST "/members/${PERSON_ID}/work-experiences" "$(json \ + --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{ + organizationId: $org, + jobTitle: "Platform Engineer", + verified: false, + verifiedBy: $by, + source: "lfxOne", + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-12-31T00:00:00.000Z" + }')" + check "POST create Acme stint" 201 \ + 'has("id") and (has("workExperiences") | not)' \ + ".organizationName == \"$ACME_NAME\"" \ + ".organizationDomains | index(\"$ACME_DOMAIN\") != null" + acme_we="$(jq -r '.id' <<<"$BODY")" + + api POST "/members/${PERSON_ID}/work-experiences" "$(json \ + --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{ + organizationId: $org, + jobTitle: "Software Engineer", + verified: false, + verifiedBy: $by, + source: "lfxOne", + startDate: "2020-01-01T00:00:00.000Z", + endDate: "2022-06-01T00:00:00.000Z" + }')" + check "POST create Globex stint" 201 + globex_we="$(jq -r '.id' <<<"$BODY")" + + api POST "/members/${PERSON_ID}/work-experiences" "$(json \ + --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{ + organizationId: $org, + jobTitle: "ignored title", + verified: false, + verifiedBy: $by, + source: "email-domain", + startDate: "2021-01-01T00:00:00.000Z", + endDate: "2023-01-01T00:00:00.000Z" + }')" + check "POST overlapping Globex email-domain row" 201 + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET groups to two visible rows" 200 \ + '.workExperiences | length == 2' \ + ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Software Engineer\"" \ + ".workExperiences[] | select(.id == \"$globex_we\") | .source | test(\"lfxOne\")" \ + ".workExperiences[] | select(.id == \"$globex_we\") | .source | test(\"email-domain\")" \ + ".workExperiences[] | select(.id == \"$globex_we\") | .startDate | startswith(\"2020-01-01\")" \ + ".workExperiences[] | select(.id == \"$globex_we\") | .endDate | startswith(\"2023-01-01\")" \ + ".workExperiences[] | select(.organizationId == \"$GLOBEX_ID\") | .id == \"$globex_we\"" + + api PUT "/members/${PERSON_ID}/work-experiences/${globex_we}" "$(json \ + --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{ + organizationId: $org, + jobTitle: "Senior Software Engineer", + verified: false, + verifiedBy: $by, + source: "lfxOne", + startDate: "2020-01-01T00:00:00.000Z", + endDate: "2022-06-01T00:00:00.000Z" + }')" + check "PUT update Globex title" 200 '.jobTitle == "Senior Software Engineer"' + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET shows updated title" 200 \ + ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Senior Software Engineer\"" + + api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \ + "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')" + check "PATCH verify Globex" 200 \ + '.verified == true' \ + ".id == \"$globex_we\"" + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET shows Globex verified" 200 \ + ".workExperiences[] | select(.id == \"$globex_we\") | .verified == true" + + api PATCH "/members/${PERSON_ID}/work-experiences/${acme_we}" \ + "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')" + check "PATCH reject Acme" 200 '.verified == false' + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET reject hides Acme" 200 \ + '.workExperiences | length == 1' \ + ".workExperiences[0].id == \"$globex_we\"" + + api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \ + "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')" + check "PATCH reject Globex" 200 + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET all rejects leave empty list" 200 '.workExperiences | length == 0' + + api POST "/members/${PERSON_ID}/work-experiences" "$(json \ + --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{ + organizationId: $org, + jobTitle: "Contractor", + verified: false, + verifiedBy: $by, + source: "lfxOne", + startDate: "2025-01-01T00:00:00.000Z", + endDate: "2025-06-01T00:00:00.000Z" + }')" + check "POST create stint for delete" 201 + doomed_we="$(jq -r '.id' <<<"$BODY")" + + api DELETE "/members/${PERSON_ID}/work-experiences/${doomed_we}" + check "DELETE work experience" 204 + + api GET "/members/${PERSON_ID}/work-experiences" + check "GET empty after delete" 200 '.workExperiences | length == 0' +} + +# ── /members/:id/maintainer-roles ──────────────────────────────────────────── + +suite_member_maintainer_roles() { + echo + echo "=== /members/:id/maintainer-roles ===" + + api GET "/members/${PERSON_ID}/maintainer-roles" + check "GET empty roles for fresh member" 200 \ + '.maintainerRoles | type == "array"' \ + '.maintainerRoles | length == 0' +} + +# ── /members/:id/project-affiliations ──────────────────────────────────────── +# PATCH needs a project the member already belongs to (activity-backed). Without +# that fixture we only cover GET + unknown-project 404. + +suite_member_project_affiliations() { + echo + echo "=== /members/:id/project-affiliations ===" + + local missing_project="00000000-0000-4000-8000-000000000099" + + api GET "/members/${PERSON_ID}/project-affiliations" + check "GET empty affiliations for fresh member" 200 \ + '.projectAffiliations | type == "array"' \ + '.projectAffiliations | length == 0' + + api PATCH "/members/${PERSON_ID}/project-affiliations/${missing_project}" \ + "$(json --arg by "$VERIFIED_BY" --arg org "$ACME_ID" '{ + verifiedBy: $by, + affiliations: [{ + organizationId: $org, + dateStart: "2024-01-01T00:00:00.000Z", + dateEnd: null + }] + }')" + check "PATCH unknown project" 404 +} + +main() { + echo "Public API e2e tests" + echo " run: $RUN_ID" + echo " base: $CDP_API_E2E_BASE_URL" + echo + + fetch_token + echo "Authenticated." + + seed + suite_organizations + suite_members + suite_member_identities + suite_member_work_experiences + suite_member_maintainer_roles + suite_member_project_affiliations + + echo + echo "=== Results ===" + printf 'Passed: %s\n' "$PASS" + printf 'Failed: %s\n' "$FAIL" + [[ $FAIL -eq 0 ]] +} + +main "$@" diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml new file mode 100644 index 0000000000..2029cffdfa --- /dev/null +++ b/.github/workflows/api-e2e-tests.yml @@ -0,0 +1,109 @@ +name: API e2e tests + +on: + schedule: + - cron: '0 1 * * *' + workflow_dispatch: + +concurrency: + group: api-e2e-tests + cancel-in-progress: false + +permissions: + contents: read + +env: + CDP_API_E2E_BASE_URL: ${{ vars.CDP_API_E2E_BASE_URL }} + +jobs: + e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Install OCI + env: + OCI_CLI_INSTALL_REF: v3.89.3 + OCI_CLI_VERSION: 3.89.3 + run: | + set -euo pipefail + curl -fsSL "https://raw.githubusercontent.com/oracle/oci-cli/${OCI_CLI_INSTALL_REF}/scripts/install/install.sh" -o install.sh + chmod +x install.sh + ./install.sh --accept-all-defaults --oci-cli-version "${OCI_CLI_VERSION}" + echo "OCI_CLI_DIR=/home/runner/bin" >> "$GITHUB_ENV" + + - name: Update PATH + run: echo "${{ env.OCI_CLI_DIR }}" >> "$GITHUB_PATH" + + - name: Deploy api-e2e + uses: ./.github/actions/node/builder + env: + CLOUD_ENV: lf-oracle-staging + ORACLE_DOCKER_USERNAME: ${{ secrets.ORACLE_DOCKER_USERNAME }} + ORACLE_DOCKER_PASSWORD: ${{ secrets.ORACLE_DOCKER_PASSWORD }} + ORACLE_USER: ${{ secrets.ORACLE_USER }} + ORACLE_TENANT: ${{ secrets.ORACLE_TENANT }} + ORACLE_REGION: ${{ secrets.ORACLE_REGION }} + ORACLE_FINGERPRINT: ${{ secrets.ORACLE_FINGERPRINT }} + ORACLE_KEY: ${{ secrets.ORACLE_KEY }} + ORACLE_CLUSTER: ${{ secrets.ORACLE_STAGING_CLUSTER }} + with: + services: api-e2e + + - name: Wait for api-e2e service to be ready + run: | + set -euo pipefail + # Builder only runs kubectl set image; wait for the new revision before health. + kubectl rollout status deployment/api-e2e-dpl --timeout=600s + health_url="${CDP_API_E2E_BASE_URL%/v1}/health" + for i in $(seq 1 36); do + curl -fsS --max-time 10 "$health_url" >/dev/null && exit 0 + sleep 10 + done + echo "api-e2e not healthy: $health_url" >&2 + exit 1 + + - name: Run Public API e2e tests + env: + AUTH0_STAGING_AUDIENCE: ${{ vars.AUTH0_STAGING_AUDIENCE }} + AUTH0_STAGING_ISSUER: ${{ vars.AUTH0_STAGING_ISSUER }} + AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }} + AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }} + run: bash .github/scripts/public-api-e2e-tests.sh + + - name: Notify Slack on failure + if: failure() + env: + CDP_ALERTS_SLACK_WEBHOOK_URL: ${{ secrets.CDP_ALERTS_SLACK_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + payload="$(jq -n \ + --arg run_url "$RUN_URL" \ + --arg event "$EVENT_NAME" \ + '{ + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: ":rotating_light: Public API e2e tests failed", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: ("*Workflow:* `API e2e tests`\n*Event:* `" + $event + "`\n*Run:* <" + $run_url + "|View run>") + } + } + ] + }')" + curl -fsS -X POST "$CDP_ALERTS_SLACK_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$payload" diff --git a/backend/src/api/public/openapi.yaml b/backend/src/api/public/openapi.yaml index 3be905cd7c..931f5934f4 100644 --- a/backend/src/api/public/openapi.yaml +++ b/backend/src/api/public/openapi.yaml @@ -855,7 +855,6 @@ paths: - name - domain - source - - logo properties: name: type: string @@ -872,7 +871,7 @@ paths: logo: type: string format: uri - description: URL of the organization's logo. + description: Optional URL of the organization's logo. example: name: Acme Corp domain: acme.com diff --git a/docs/adr/0012-api-e2e-test-architecture.md b/docs/adr/0012-api-e2e-test-architecture.md new file mode 100644 index 0000000000..42b33011d1 --- /dev/null +++ b/docs/adr/0012-api-e2e-test-architecture.md @@ -0,0 +1,140 @@ +# ADR-0012: API e2e test architecture + +**Date**: 2026-07-25 +**Status**: accepted +**Deciders**: Yeganathan S + +## Context + +We want to catch API regressions on main sooner (routing, validation, and the synchronous HTTP response contract with a valid token) instead of waiting for a consumer to hit a problem. Manually running the test script is easy to forget, pollutes staging data, depends on existing fixtures, and is not something we can rely on as a team. + +A more complete API e2e harness in the server test setup (PR CI, isolated fixtures, typed assertions) is planned for future work. Until then, we need a simple automated check that runs against an isolated environment and gives us confidence that the deployed API is still working as expected. + +The first test suite covers the Public API. These endpoints are the external contract our consumers rely on, so they are the best place to start. We can add more API e2e suites over time using the same runtime and suite pattern as needed. + +This ADR covers the runtime and isolation architecture for API e2e tests. The design of the test suites themselves is covered in [ADR-0013](./0013-api-e2e-test-suite-design.md). The current suite lives in [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh). + +## Decision + +Run **scheduled API e2e regression tests** (intentionally thin) via GitHub Actions against a dedicated `api-e2e` deployment of `main`, with data in a separate database on the existing staging Postgres host. Coverage starts with **Public API** endpoints; the same approach can extend to internal API e2e tests later if needed. + +```mermaid +flowchart LR + subgraph CI["GitHub Actions (scheduled)"] + deploy["Deploy main → api-e2e"] + tests["Run e2e test script"] + alert["Slack #cdp-alerts"] + deploy --> tests + tests -- failure --> alert + end + + auth0["Auth0
staging issuer + audience"] + + subgraph CLUSTER["Staging cluster"] + apiE2E["api-e2e"] + apiStaging["staging api"] + shared["Redis · OpenSearch · Temporal"] + end + + subgraph PG["Staging Postgres host"] + e2edb[("crowd_api_e2e")] + stagingdb[("staging database")] + end + + deploy --> apiE2E + tests --> auth0 + tests -- HTTPS --> apiE2E + apiE2E --> e2edb + apiE2E --> shared + apiStaging --> stagingdb + apiStaging --> shared +``` + +### Stack + +- **`api-e2e`**: normal API deployment of `main` (same image, staging-shaped config). One deliberate difference: `CROWD_DB_DATABASE=crowd_api_e2e`. It shares staging Redis, OpenSearch, and Temporal. The e2e tests do not assert on those systems; cloning them would cost more without improving the HTTP contract signal we care about. +- **`crowd_api_e2e`**: a separate **database name** on the existing staging Postgres host, not a second Postgres server. Same Flyway migrations as staging; no e2e-only schema. +- **Auth0**: LFX One’s staging M2M client (issuer and audience unchanged). Only the API base URL points at `api-e2e`. CI talks to the API over HTTPS only. Secrets stay in GitHub Actions / env: never committed, never logged (`set -x` refused by the script). +- **Scope**: API e2e tests, **Public API first** (external contract). Internal endpoints can get suites later when they need them ([ADR-0013](./0013-api-e2e-test-suite-design.md)). + +### What a scheduled run does + +1. Deploy `main` to `api-e2e` +2. Authenticate with Auth0 +3. Seed fixtures over HTTP (`RUN_ID`-tagged names and domains) +4. Call endpoints and assert status + body +5. Alert `#cdp-alerts` on failure + +`RUN_ID` is `GITHUB_RUN_ID` (or a local timestamp) plus a `${RANDOM}` suffix. The suffix matters because workflow reruns reuse the same `GITHUB_RUN_ID`. Fixtures include it so runs do not collide and leftovers stay easy to spot. The suite seeds over HTTP and does not require a wiped database between runs, only a reachable `api-e2e`. + +### What we assert (and what we do not) + +- **Assert**: routing, validation, sync response shape and critical flows using a valid fully scoped token. +- **Do not assert**: Temporal / OpenSearch eventual outcomes. Some writes await Temporal accepting a signal before the HTTP response returns; async worker work stays out of scope for this suite. + +### Not a PR gate + +A single shared `api-e2e` cannot safely gate concurrent PRs, because deploys and DB use would step on each other. This stack is a **scheduled check of what `main` deployed**. Per-PR API e2e waits on the backlog harness. + +### Database reset (ops) + +`reset_api_e2e_test_db.sh` runs on the EC2 host used to reach staging Postgres over SSH. It creates `crowd_api_e2e` if needed, applies normal Flyway migrations, truncates tables, and seeds the default tenant. + +Run it after migrations land on `main`, or when leftover e2e test rows should be cleared. Keep that host’s crowd.dev checkout on latest `main` so Flyway matches the deployed `api-e2e`. + +Reset stays manual: the e2e database is only reachable through that EC2/SSH path. Wiring wipe/migrate into GitHub Actions would mean exposing DB access there, which is not worth it for an occasional ops step. + +## Alternatives Considered + +### Alternative 1: Hand-run e2e tests against shared staging + +- **Pros**: No new deploy or database. +- **Cons**: Pollutes staging; fragile ambient fixtures; cannot expect everyone to run it. +- **Why not**: Rejected as a team practice. + +### Alternative 2: Point shared staging `api` at an e2e database for the run + +- **Pros**: No second API deployment. +- **Cons**: Staging users and data path become coupled to test runs; high blast radius. +- **Why not**: Isolation requires a dedicated API process, not only a DB name. + +### Alternative 3: Separate Postgres server (or full clone of Redis / OpenSearch / Temporal) + +- **Pros**: Stronger isolation. +- **Cons**: More infra and cost; little extra signal for HTTP contract e2e tests. +- **Why not**: A second database name plus shared side deps is enough for this suite. + +### Alternative 4: Use shared `api-e2e` as a PR CI gate + +- **Pros**: Feedback on the branch. +- **Cons**: Concurrent PRs overwrite deploys and data; flaky and misleading. +- **Why not**: Needs per-PR environments or a different harness. Out of scope until the backlog work lands. + +### Alternative 5: Wait for the backlog PR-time API e2e harness before any automation + +- **Pros**: One end state only. +- **Cons**: No automated HTTP safety net meanwhile. +- **Why not**: Scheduled API e2e tests are an acceptable bridge. + +## Consequences + +### Positive + +- Catches Public API regressions on `main` sooner, without hand runs or staging DB pollution. +- Clear ownership: runtime here; suite shape in [ADR-0013](./0013-api-e2e-test-suite-design.md); script in `.github/scripts/`. +- Cheap isolation (DB name + small always-on `api-e2e`). +- Public API contract covered first; internal e2e tests can be added later only when needed. + +### Negative + +- Validates deployed `main`, not unmerged PR branches. +- Soft leftovers in `crowd_api_e2e` until someone runs the reset script. +- Bash e2e tests will not scale forever as a full matrix (by design; keep it thin). + +### Risks + +- `api-e2e` drifts from staging config → treat it as a clone with only `CROWD_DB_DATABASE` different; deploy `main` on the schedule. +- Flyway on the EC2 checkout lags `main` → reset script refuses to run until the checkout is updated. +- Pressure to assert async side effects → push that to worker/workflow tests; keep this suite on the sync HTTP contract. +- Backlog PR-time API e2e delayed → these scheduled e2e tests remain the safety net; that is acceptable. +- Scope creep into internal APIs before Public coverage is solid → keep Public as the focus unless there is a concrete need. diff --git a/docs/adr/0013-api-e2e-test-suite-design.md b/docs/adr/0013-api-e2e-test-suite-design.md new file mode 100644 index 0000000000..3eba457359 --- /dev/null +++ b/docs/adr/0013-api-e2e-test-suite-design.md @@ -0,0 +1,73 @@ +# ADR-0013: API e2e test suite design + +**Date**: 2026-07-24 +**Status**: accepted +**Deciders**: Yeganathan S + +## Context + +API e2e tests need a maintainable HTTP suite pattern that can grow as endpoints are added. Without shared structure, suites tend to split by HTTP method, accumulate one-off asserts, and become hard to extend or review. + +We want a thin, boring pattern: shared helpers, clear grouping, and an obvious place to add the next resource. This is how we write durable e2e cases until a fuller PR-time API e2e harness exists (backlog). + +This ADR is the **suite design** (how tests are written). Runtime, isolation, scheduled CI, which surfaces we cover, and ops live in [ADR-0012](./0012-api-e2e-test-architecture.md). + +## Decision + +Implement API e2e tests as HTTP bash script(s) with this structure. Current entrypoint: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh). New suites should reuse this pattern (same script or additional scripts), not invent a parallel style. + +The suite is **thin**: assert the HTTP contract and critical flows so regressions show up early. Leave exhaustive edge-case matrices to unit or focused contract tests. + +| Layer | Role | +| --- | --- | +| Helpers | `api` (call), `check` (soft status + body preds), `require` (hard fail for seed) | +| Seed | Create shared fixtures once per run over HTTP | +| Suites | One `suite_*` per **resource path**, not per HTTP method | +| Cases | One exchange: `api` then `check`. Stateful resources stay ordered as a short story | +| Registration | Call each suite from `main` | + +**Rules of thumb** + +- Group by resource (`/organizations`, `/members/:id/identities`, …). Put GET and POST for the same path in the same suite. +- Prefer one meaningful `check` per request over many micro-asserts. +- Cover the contract and critical flows. Leave deep normalization / matrix cases to unit or focused contract tests. +- Keep seed HTTP-only. Suites that need non-API fixtures (e.g. activity-backed projects) may cover the route with empty/error paths until a fixture exists. +- Name people and orgs like real data so failures read naturally. + +## Alternatives Considered + +### Alternative 1: One suite per HTTP method (`suite_get_*`, `suite_post_*`) + +- **Pros**: Mirrors OpenAPI operation IDs one-to-one. +- **Cons**: Splits one resource across files/functions; harder to see the full surface; encourages duplicate setup. +- **Why not**: Resource path is the stable unit as the API grows. + +### Alternative 2: Flat table of request/response cases only + +- **Pros**: Very uniform; easy code-gen later. +- **Cons**: Poor fit for stateful flows (create → list → update → verify). +- **Why not**: We need both independent cases and short ordered stories. + +### Alternative 3: No shared structure (each contributor invents a style) + +- **Pros**: Maximum local freedom. +- **Cons**: Inconsistent quality; expensive to review and extend. +- **Why not**: A thin shared pattern scales better than ad-hoc scripts. + +## Consequences + +### Positive + +- Adding an endpoint is: open (or create) the resource suite, add `api` + `check`, register in `main` if new. +- Reviews focus on cases, not harness inventiveness. +- Soft `check` keeps the run going so one failure does not hide the rest. + +### Negative + +- Stateful suites are order-dependent; a mid-story failure can cascade. +- Bash is less ergonomic than a typed test runner for large suites. + +### Risks + +- Script grows past a comfortable size → split helpers vs suites, or one file per resource / surface, without changing the grouping rule. +- Pressure to turn e2e tests into a full matrix → push depth to unit/contract tests; keep this suite thin. diff --git a/docs/adr/README.md b/docs/adr/README.md index cc3e7bbf26..00e36a152d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,8 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions. | [ADR-0009](./0009-packagist-worker-design-decisions.md) | Packagist worker — design decisions | accepted | 2026-07-13 | | [ADR-0010](./0010-security-contacts-worker.md) | Security contacts — tiered extraction, confidence scoring, and Temporal batch ingestion | accepted | 2026-07-21 | | [ADR-0011](./0011-mailinglist-skip-unparseable-dates.md) | Skip mailing list activities with unparseable/implausible dates | accepted | 2026-07-19 | +| [ADR-0012](./0012-api-e2e-test-architecture.md) | API e2e test architecture | accepted | 2026-07-25 | +| [ADR-0013](./0013-api-e2e-test-suite-design.md) | API e2e test suite design | accepted | 2026-07-24 | ## Why ADRs? diff --git a/scripts/builders/backend.env b/scripts/builders/backend.env index ad27ed5612..d49a53b4ef 100644 --- a/scripts/builders/backend.env +++ b/scripts/builders/backend.env @@ -1,4 +1,4 @@ DOCKERFILE="./services/docker/Dockerfile.backend" CONTEXT="../" REPO="sjc.ocir.io/axbydjxa5zuh/backend" -SERVICES="api job-generator" \ No newline at end of file +SERVICES="api api-e2e job-generator" \ No newline at end of file