diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml new file mode 100644 index 0000000..0d0c444 --- /dev/null +++ b/.github/workflows/e2e-nightly.yml @@ -0,0 +1,291 @@ +name: ๐ŸŒ™ E2E Nightly (latest ThunderID) + +# Runs the same E2E suite as pr-builder.yml's `e2e` job, against the same always-latest ThunderID +# release. The two differ only in trigger: this one runs on a schedule regardless of PR activity, +# so a ThunderID release that breaks something surfaces even on a day with no relevant PR open โ€” +# the per-PR job (see pr-builder.yml) is label-gated and opt-in, not scheduled. + +on: + schedule: + - cron: "30 18 * * *" # 18:30 UTC daily + workflow_dispatch: + +env: + NODE_VERSION: "lts/*" + +jobs: + e2e-nightly: + name: ๐ŸŒ™ E2E (sample apps, latest ThunderID) + runs-on: ubuntu-latest + timeout-minutes: 40 + # Least privilege: this job never pushes, comments, or writes to the repo โ€” only the default + # GITHUB_TOKEN's read access is needed for checkout. + permissions: + contents: read + steps: + - name: ๐Ÿ“ฅ Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: โš™๏ธ Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: ๐Ÿ“ฆ Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: latest + run_install: false + + - name: ๐Ÿ—„๏ธ Cache pnpm store + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: ๐Ÿ“ฆ Install Dependencies + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ”จ Build SDK packages + run: pnpm build + + - name: ๐Ÿš€ Install, set up, and start ThunderID (latest, via npx) + id: install + working-directory: tests/e2e + # See the identically-named step in pr-builder.yml's `e2e` job: `npx thunderid` resolves + # and downloads the latest release itself and runs setup.sh non-interactively, then we + # start it ourselves with `start.sh` since npx thunderid's own background process doesn't + # outlive its own (expected) REPL/TTY failure on this runner. + run: | + # setsid detaches into a new session with no controlling terminal โ€” see the identical + # note in pr-builder.yml's `e2e` job / tests/e2e/run-e2e.sh. + THUNDERID_ADMIN_USERNAME=admin THUNDERID_ADMIN_PASSWORD=admin \ + setsid npx --yes thunderid --verbose < /dev/null || true + + DIST_HOME=$(find "$PWD/thunderid" -maxdepth 1 -type d -name 'v*' | head -1) + if [ -z "$DIST_HOME" ]; then + echo "ERROR: npx thunderid did not produce an installed ThunderID release" && exit 1 + fi + echo "dist_home=$DIST_HOME" >> "$GITHUB_OUTPUT" + + chmod +x "$DIST_HOME/start.sh" + (cd "$DIST_HOME" && setsid ./start.sh &) + for i in $(seq 1 60); do + curl -skf https://localhost:8090/health/liveness && exit 0 + sleep 2 + done + echo "ERROR: ThunderID server did not become ready" && exit 1 + + - name: ๐Ÿ”‘ Obtain admin token + id: admin-token + # See the identically-named step in pr-builder.yml's `e2e` job โ€” the same bash sequence, no + # reference to thunder-id/thunderid's branch or a pinned commit. + run: | + REDIRECT_URI="https://localhost:8090/console" + CODE_VERIFIER=$(openssl rand -hex 32 | cut -c1-43) + CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + HEADERS_FILE=$(mktemp) + curl -sk -o /dev/null -D "$HEADERS_FILE" \ + -G "https://localhost:8090/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$REDIRECT_URI" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=https://localhost:8090/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$CODE_CHALLENGE" \ + --data-urlencode "code_challenge_method=S256" + + LOCATION=$(grep -i "^location:" "$HEADERS_FILE" | tr -d '\r' | sed 's/^[Ll]ocation: //') + rm -f "$HEADERS_FILE" + AUTH_ID=$(echo "$LOCATION" | sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p') + EXEC_ID=$(echo "$LOCATION" | sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p') + [ -n "$AUTH_ID" ] && [ -n "$EXEC_ID" ] || { echo "ERROR: Failed to parse authId/executionId from authorize redirect."; exit 1; } + + PROMPT_RESP=$(curl -sk -X POST "https://localhost:8090/flow/execute" \ + -H "Content-Type: application/json" -d "{\"executionId\": \"$EXEC_ID\"}") + CHALLENGE_TOKEN=$(echo "$PROMPT_RESP" | jq -r '.challengeToken // empty') + [ -n "$CHALLENGE_TOKEN" ] || { echo "ERROR: Flow execution did not return a challenge token: $PROMPT_RESP"; exit 1; } + + FLOW_RESP=$(curl -sk -X POST "https://localhost:8090/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg executionId "$EXEC_ID" --arg challengeToken "$CHALLENGE_TOKEN" \ + --arg username "admin" --arg password "admin" --arg action "action_001" \ + '{executionId: $executionId, challengeToken: $challengeToken, inputs: {username: $username, password: $password}, action: $action}')") + ASSERTION=$(echo "$FLOW_RESP" | jq -r '.assertion // empty') + [ -n "$ASSERTION" ] || { echo "ERROR: Admin authentication failed: $FLOW_RESP"; exit 1; } + + CALLBACK_RESP=$(curl -sk -X POST "https://localhost:8090/oauth2/auth/callback" \ + -H "Content-Type: application/json" -d "{\"authId\": \"$AUTH_ID\", \"assertion\": \"$ASSERTION\"}") + AUTH_CODE=$(echo "$CALLBACK_RESP" | jq -r '.redirect_uri // empty' | sed 's/.*[?&]code=\([^&]*\).*/\1/') + [ -n "$AUTH_CODE" ] || { echo "ERROR: OAuth2 callback did not return an authorization code: $CALLBACK_RESP"; exit 1; } + + TOKEN_RESP=$(curl -sk -X POST "https://localhost:8090/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$AUTH_CODE" \ + --data-urlencode "redirect_uri=$REDIRECT_URI" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=https://localhost:8090/mcp" \ + --data-urlencode "code_verifier=$CODE_VERIFIER") + TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token // empty') + [ -n "$TOKEN" ] || { echo "ERROR: Failed to obtain admin access token: $TOKEN_RESP"; exit 1; } + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: ๐Ÿ“ Import sample app OAuth2 clients + # See the identically-named step in pr-builder.yml's `e2e` job โ€” the same bash sequence, no + # reference to thunder-id/thunderid's branch or a pinned commit. + env: + ADMIN_TOKEN: ${{ steps.admin-token.outputs.token }} + run: | + CONTENT=$(jq -Rs . < tests/e2e/thunderid-config/sample-apps.yaml) + VARIABLES=$(jq -n '{ + BROWSER_CLIENT_ID: "JS_SDK_E2E_BROWSER", BROWSER_REDIRECT_URIS: ["http://localhost:5173"], + REACT_CLIENT_ID: "JS_SDK_E2E_REACT", REACT_REDIRECT_URIS: ["http://localhost:5174"], + VUE_CLIENT_ID: "JS_SDK_E2E_VUE", VUE_REDIRECT_URIS: ["http://localhost:5175"], + NEXTJS_CLIENT_ID: "JS_SDK_E2E_NEXTJS", NEXTJS_CLIENT_SECRET: "e2e-nextjs-secret", NEXTJS_REDIRECT_URIS: ["http://localhost:3001"], + NUXT_CLIENT_ID: "JS_SDK_E2E_NUXT", NUXT_CLIENT_SECRET: "e2e-nuxt-secret", NUXT_REDIRECT_URIS: ["http://localhost:3002/api/auth/callback"], NUXT_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3002/"], + EXPRESS_CLIENT_ID: "JS_SDK_E2E_EXPRESS", EXPRESS_CLIENT_SECRET: "e2e-express-secret", EXPRESS_REDIRECT_URIS: ["http://localhost:3000/login"], EXPRESS_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3000/logout"], + NODE_CLIENT_ID: "JS_SDK_E2E_NODE", NODE_CLIENT_SECRET: "e2e-node-secret", + ALL_ORIGINS: ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000", "http://localhost:3001", "http://localhost:3002"] + }') + RESPONSE_FILE=$(mktemp) + HTTP_STATUS=$(curl -sk -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "https://localhost:8090/import" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d "{\"content\": $CONTENT, \"variables\": $VARIABLES, \"options\": {\"upsert\": true}}") + + if [ "$HTTP_STATUS" != "200" ]; then + echo "ERROR: import returned HTTP $HTTP_STATUS:"; cat "$RESPONSE_FILE"; echo ""; exit 1 + fi + FAILED_COUNT=$(jq -r '.summary.failed // 0' "$RESPONSE_FILE") + if [ "$FAILED_COUNT" != "0" ]; then + echo "ERROR: import had $FAILED_COUNT failed resource(s):"; cat "$RESPONSE_FILE"; echo ""; exit 1 + fi + rm -f "$RESPONSE_FILE" + + - name: ๐Ÿ”„ Restart the server with security enabled + working-directory: ${{ steps.install.outputs.dist_home }} + run: | + # `pkill -f start.sh` only matches the wrapper script, not the server binary it launches + # in the foreground (a separate PID that SIGKILL to the wrapper never reaches) โ€” kill by + # port instead, and confirm the old server is actually gone before starting a new one, + # since starting a replacement while the old process still holds the port would let the + # old (pre-import) server keep answering liveness checks and silently pass this step. + lsof -ti tcp:8090 | xargs -r kill -9 + for i in $(seq 1 30); do + curl -skf https://localhost:8090/health/liveness > /dev/null 2>&1 || break + sleep 1 + done + if curl -skf https://localhost:8090/health/liveness > /dev/null 2>&1; then + echo "ERROR: previous ThunderID server is still reachable after kill" && exit 1 + fi + + setsid ./start.sh & + for i in $(seq 1 60); do + curl -skf https://localhost:8090/health/liveness && exit 0 + sleep 2 + done + echo "ERROR: ThunderID server did not restart" && exit 1 + + - name: ๐Ÿ“ Write sample app .env files + # See the identically-named step in pr-builder.yml's `e2e` job for why this writes each + # .env directly instead of using the apps' own `prepare-dev.cjs --flow=redirect`. + run: | + NEXTJS_SECRET=$(openssl rand -base64 32) + NUXT_SECRET=$(openssl rand -base64 32) + + write_env() { + local file=$1; shift + printf '%s\n' "$@" > "$file" + } + + write_env samples/browser/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_BROWSER" + + write_env samples/react/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_REACT" + + write_env samples/vue/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_VUE" + + write_env samples/express/quickstart/.env \ + "THUNDERID_BASE_URL=https://localhost:8090" \ + "THUNDERID_CLIENT_ID=JS_SDK_E2E_EXPRESS" \ + "THUNDERID_CLIENT_SECRET=e2e-express-secret" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + write_env samples/nextjs/quickstart/.env \ + "NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ + "NEXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NEXTJS" \ + "THUNDERID_CLIENT_SECRET=e2e-nextjs-secret" \ + "THUNDERID_SECRET=${NEXTJS_SECRET}" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + write_env samples/nuxt/quickstart/.env \ + "NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ + "NUXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NUXT" \ + "THUNDERID_CLIENT_SECRET=e2e-nuxt-secret" \ + "THUNDERID_SESSION_SECRET=${NUXT_SECRET}" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + - name: ๐Ÿš€ Start sample apps + run: | + ( cd samples/browser/quickstart && pnpm exec vite --port 5173 & ) + ( cd samples/react/quickstart && pnpm exec vite --port 5174 & ) + ( cd samples/vue/quickstart && pnpm exec vite --port 5175 & ) + ( cd samples/nextjs/quickstart && pnpm exec next dev -p 3001 & ) + ( cd samples/nuxt/quickstart && pnpm exec nuxt dev --port 3002 & ) + ( cd samples/express/quickstart && pnpm exec node --env-file-if-exists=.env index.mjs & ) + for url in http://localhost:5173 http://localhost:5174 http://localhost:5175 http://localhost:3000 http://localhost:3001 http://localhost:3002; do + ready=0 + for i in $(seq 1 60); do + curl -sf "$url" > /dev/null && ready=1 && break + sleep 2 + done + if [ "$ready" != "1" ]; then + echo "ERROR: $url did not become ready after 120s" && exit 1 + fi + done + + - name: ๐ŸŽญ Install Playwright browsers + working-directory: tests/e2e + run: npx playwright install --with-deps chromium + + - name: ๐Ÿงช Run E2E tests + working-directory: tests/e2e + env: + SERVER_URL: https://localhost:8090 + ADMIN_USERNAME: admin + ADMIN_PASSWORD: admin + ADMIN_TOKEN: ${{ steps.admin-token.outputs.token }} + TEST_USER_USERNAME: e2e-test-user + TEST_USER_PASSWORD: E2ePassword@123 + BROWSER_APP_URL: http://localhost:5173 + REACT_APP_URL: http://localhost:5174 + VUE_APP_URL: http://localhost:5175 + NEXTJS_APP_URL: http://localhost:3001 + NUXT_APP_URL: http://localhost:3002 + EXPRESS_APP_URL: http://localhost:3000 + THUNDERID_BASE_URL: https://localhost:8090 + THUNDERID_CLIENT_ID: JS_SDK_E2E_NODE + THUNDERID_CLIENT_SECRET: e2e-node-secret + NODE_TLS_REJECT_UNAUTHORIZED: "0" + run: npx playwright test + + - name: ๐Ÿ“ค Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: playwright-report-nightly + path: tests/e2e/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index 1f7bba4..e66035e 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -2,7 +2,7 @@ name: ๐Ÿ‘ท๐Ÿ› ๏ธ PR Builder on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, labeled] merge_group: workflow_dispatch: @@ -98,3 +98,312 @@ jobs: - name: ๐Ÿงช Test run: pnpm --filter '!./packages/**' --filter '!./samples/**' test + + e2e: + name: ๐ŸŽญ E2E (sample apps) + # Label-gated, mirroring thunderid's own pr-builder.yml `trigger-pr-builder` pattern โ€” a real + # backend + six sample apps is expensive enough that it shouldn't run on every push by + # default. Always runs on merge_group/workflow_dispatch. + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'trigger-e2e') + runs-on: ubuntu-latest + timeout-minutes: 40 + # Least privilege: this job never pushes, comments, or writes to the repo โ€” only the default + # GITHUB_TOKEN's read access is needed for checkout. + permissions: + contents: read + steps: + - name: ๐Ÿ“ฅ Checkout Code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # Matches thunder-id/thunderid's own checkout steps: the checked-out repo never needs + # git push/fetch credentials in this job, so don't leave the token available to be + # abused by anything that runs afterward (including the third-party composite actions + # invoked later in this job). + persist-credentials: false + + - name: โš™๏ธ Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: ๐Ÿ“ฆ Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: latest + run_install: false + + - name: ๐Ÿ—„๏ธ Cache pnpm store + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: ๐Ÿ“ฆ Install Dependencies + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ”จ Build SDK packages + # Sample apps consume workspace:* SDK packages โ€” this is what actually puts the PR's + # changes under test. + run: pnpm build + + - name: ๐Ÿš€ Install, set up, and start ThunderID (latest, via npx) + id: install + working-directory: tests/e2e + # Mirrors download_and_start_server() in tests/e2e/run-e2e.sh: `npx thunderid` resolves the + # latest release itself, no GitHub API or thunderid.dev call of our own and no version + # pinned in this repo or workflow, downloads it, and runs setup.sh non-interactively via + # the THUNDERID_ADMIN_* env vars. It always finishes by trying to attach its interactive + # REPL, which needs a real TTY this runner doesn't have, so it fails; that's expected, and + # by then the release is already downloaded and fully set up on disk, so the exit code is + # ignored. We start it ourselves afterward with `start.sh`, since npx thunderid's own + # (REPL-attached) background process doesn't outlive the npx invocation. This is the first + # thing on this fresh runner to touch the ThunderID CLI's state, so there is no risk of it + # picking up a stale "active version" the way a long-lived dev machine could. + run: | + # setsid detaches into a new session with no controlling terminal, so npx thunderid's + # REPL (which opens /dev/tty directly, bypassing stdin redirection) fails to attach + # instead of hanging the step waiting for keystrokes that never come. Runners have no + # controlling terminal anyway, but this keeps the behavior guaranteed rather than + # incidental โ€” see the identical note in tests/e2e/run-e2e.sh. + THUNDERID_ADMIN_USERNAME=admin THUNDERID_ADMIN_PASSWORD=admin \ + setsid npx --yes thunderid --verbose < /dev/null || true + + DIST_HOME=$(find "$PWD/thunderid" -maxdepth 1 -type d -name 'v*' | head -1) + if [ -z "$DIST_HOME" ]; then + echo "ERROR: npx thunderid did not produce an installed ThunderID release" && exit 1 + fi + echo "dist_home=$DIST_HOME" >> "$GITHUB_OUTPUT" + + chmod +x "$DIST_HOME/start.sh" + (cd "$DIST_HOME" && setsid ./start.sh &) + for i in $(seq 1 60); do + curl -skf https://localhost:8090/health/liveness && exit 0 + sleep 2 + done + echo "ERROR: ThunderID server did not become ready" && exit 1 + + - name: ๐Ÿ”‘ Obtain admin token + id: admin-token + # Mirrors mint_admin_token() in tests/e2e/run-e2e.sh โ€” the same OAuth2 authorization_code + + # PKCE sequence against the CONSOLE app, done directly in bash instead of through + # thunder-id/thunderid's obtain-admin-token composite action, so this job carries no + # reference to that repo's branch or a pinned commit at all. + run: | + REDIRECT_URI="https://localhost:8090/console" + CODE_VERIFIER=$(openssl rand -hex 32 | cut -c1-43) + CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + HEADERS_FILE=$(mktemp) + curl -sk -o /dev/null -D "$HEADERS_FILE" \ + -G "https://localhost:8090/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$REDIRECT_URI" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=https://localhost:8090/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$CODE_CHALLENGE" \ + --data-urlencode "code_challenge_method=S256" + + LOCATION=$(grep -i "^location:" "$HEADERS_FILE" | tr -d '\r' | sed 's/^[Ll]ocation: //') + rm -f "$HEADERS_FILE" + AUTH_ID=$(echo "$LOCATION" | sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p') + EXEC_ID=$(echo "$LOCATION" | sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p') + [ -n "$AUTH_ID" ] && [ -n "$EXEC_ID" ] || { echo "ERROR: Failed to parse authId/executionId from authorize redirect."; exit 1; } + + PROMPT_RESP=$(curl -sk -X POST "https://localhost:8090/flow/execute" \ + -H "Content-Type: application/json" -d "{\"executionId\": \"$EXEC_ID\"}") + CHALLENGE_TOKEN=$(echo "$PROMPT_RESP" | jq -r '.challengeToken // empty') + [ -n "$CHALLENGE_TOKEN" ] || { echo "ERROR: Flow execution did not return a challenge token: $PROMPT_RESP"; exit 1; } + + FLOW_RESP=$(curl -sk -X POST "https://localhost:8090/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg executionId "$EXEC_ID" --arg challengeToken "$CHALLENGE_TOKEN" \ + --arg username "admin" --arg password "admin" --arg action "action_001" \ + '{executionId: $executionId, challengeToken: $challengeToken, inputs: {username: $username, password: $password}, action: $action}')") + ASSERTION=$(echo "$FLOW_RESP" | jq -r '.assertion // empty') + [ -n "$ASSERTION" ] || { echo "ERROR: Admin authentication failed: $FLOW_RESP"; exit 1; } + + CALLBACK_RESP=$(curl -sk -X POST "https://localhost:8090/oauth2/auth/callback" \ + -H "Content-Type: application/json" -d "{\"authId\": \"$AUTH_ID\", \"assertion\": \"$ASSERTION\"}") + AUTH_CODE=$(echo "$CALLBACK_RESP" | jq -r '.redirect_uri // empty' | sed 's/.*[?&]code=\([^&]*\).*/\1/') + [ -n "$AUTH_CODE" ] || { echo "ERROR: OAuth2 callback did not return an authorization code: $CALLBACK_RESP"; exit 1; } + + TOKEN_RESP=$(curl -sk -X POST "https://localhost:8090/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$AUTH_CODE" \ + --data-urlencode "redirect_uri=$REDIRECT_URI" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=https://localhost:8090/mcp" \ + --data-urlencode "code_verifier=$CODE_VERIFIER") + TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token // empty') + [ -n "$TOKEN" ] || { echo "ERROR: Failed to obtain admin access token: $TOKEN_RESP"; exit 1; } + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: ๐Ÿ“ Import sample app OAuth2 clients + # Mirrors import_sample_apps_config() in tests/e2e/run-e2e.sh โ€” a direct POST to /import, + # done in bash instead of through thunder-id/thunderid's import-declarative-config + # composite action, so this job carries no reference to that repo's branch or a pinned + # commit at all. + env: + ADMIN_TOKEN: ${{ steps.admin-token.outputs.token }} + run: | + CONTENT=$(jq -Rs . < tests/e2e/thunderid-config/sample-apps.yaml) + VARIABLES=$(jq -n '{ + BROWSER_CLIENT_ID: "JS_SDK_E2E_BROWSER", BROWSER_REDIRECT_URIS: ["http://localhost:5173"], + REACT_CLIENT_ID: "JS_SDK_E2E_REACT", REACT_REDIRECT_URIS: ["http://localhost:5174"], + VUE_CLIENT_ID: "JS_SDK_E2E_VUE", VUE_REDIRECT_URIS: ["http://localhost:5175"], + NEXTJS_CLIENT_ID: "JS_SDK_E2E_NEXTJS", NEXTJS_CLIENT_SECRET: "e2e-nextjs-secret", NEXTJS_REDIRECT_URIS: ["http://localhost:3001"], + NUXT_CLIENT_ID: "JS_SDK_E2E_NUXT", NUXT_CLIENT_SECRET: "e2e-nuxt-secret", NUXT_REDIRECT_URIS: ["http://localhost:3002/api/auth/callback"], NUXT_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3002/"], + EXPRESS_CLIENT_ID: "JS_SDK_E2E_EXPRESS", EXPRESS_CLIENT_SECRET: "e2e-express-secret", EXPRESS_REDIRECT_URIS: ["http://localhost:3000/login"], EXPRESS_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3000/logout"], + NODE_CLIENT_ID: "JS_SDK_E2E_NODE", NODE_CLIENT_SECRET: "e2e-node-secret", + ALL_ORIGINS: ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000", "http://localhost:3001", "http://localhost:3002"] + }') + RESPONSE_FILE=$(mktemp) + HTTP_STATUS=$(curl -sk -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST "https://localhost:8090/import" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d "{\"content\": $CONTENT, \"variables\": $VARIABLES, \"options\": {\"upsert\": true}}") + + if [ "$HTTP_STATUS" != "200" ]; then + echo "ERROR: import returned HTTP $HTTP_STATUS:"; cat "$RESPONSE_FILE"; echo ""; exit 1 + fi + FAILED_COUNT=$(jq -r '.summary.failed // 0' "$RESPONSE_FILE") + if [ "$FAILED_COUNT" != "0" ]; then + echo "ERROR: import had $FAILED_COUNT failed resource(s):"; cat "$RESPONSE_FILE"; echo ""; exit 1 + fi + rm -f "$RESPONSE_FILE" + + - name: ๐Ÿ”„ Restart the server with security enabled + working-directory: ${{ steps.install.outputs.dist_home }} + run: | + # `pkill -f start.sh` only matches the wrapper script, not the server binary it launches + # in the foreground (a separate PID that SIGKILL to the wrapper never reaches) โ€” kill by + # port instead, and confirm the old server is actually gone before starting a new one, + # since starting a replacement while the old process still holds the port would let the + # old (pre-import) server keep answering liveness checks and silently pass this step. + lsof -ti tcp:8090 | xargs -r kill -9 + for i in $(seq 1 30); do + curl -skf https://localhost:8090/health/liveness > /dev/null 2>&1 || break + sleep 1 + done + if curl -skf https://localhost:8090/health/liveness > /dev/null 2>&1; then + echo "ERROR: previous ThunderID server is still reachable after kill" && exit 1 + fi + + setsid ./start.sh & + for i in $(seq 1 60); do + curl -skf https://localhost:8090/health/liveness && exit 0 + sleep 2 + done + echo "ERROR: ThunderID server did not restart" && exit 1 + + - name: ๐Ÿ“ Write sample app .env files + # Each app needs its own OAuth2 client wired in before it starts (see the OAuth2 clients + # imported above). The apps' own `prepare-dev.cjs --flow=redirect` helper looks like the + # natural fit for nextjs/nuxt but isn't: it rewrites the sample's checked-in package.json + # (workspace:* -> latest, defeating the point of testing this PR's own SDK changes) and + # blanks any value matching its own placeholders โ€” including the literal string + # `https://localhost:8090`, which is also our real server URL. Writing each .env directly + # sidesteps both; variable names come from each app's own scripts/prepare-dev.cjs + # (native-flow-only vars for nextjs/nuxt are omitted). + run: | + NEXTJS_SECRET=$(openssl rand -base64 32) + NUXT_SECRET=$(openssl rand -base64 32) + + write_env() { + local file=$1; shift + printf '%s\n' "$@" > "$file" + } + + write_env samples/browser/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_BROWSER" + + write_env samples/react/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_REACT" + + write_env samples/vue/quickstart/.env \ + "VITE_THUNDERID_BASE_URL=https://localhost:8090" \ + "VITE_THUNDERID_CLIENT_ID=JS_SDK_E2E_VUE" + + write_env samples/express/quickstart/.env \ + "THUNDERID_BASE_URL=https://localhost:8090" \ + "THUNDERID_CLIENT_ID=JS_SDK_E2E_EXPRESS" \ + "THUNDERID_CLIENT_SECRET=e2e-express-secret" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + write_env samples/nextjs/quickstart/.env \ + "NEXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ + "NEXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NEXTJS" \ + "THUNDERID_CLIENT_SECRET=e2e-nextjs-secret" \ + "THUNDERID_SECRET=${NEXTJS_SECRET}" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + write_env samples/nuxt/quickstart/.env \ + "NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090" \ + "NUXT_PUBLIC_THUNDERID_CLIENT_ID=JS_SDK_E2E_NUXT" \ + "THUNDERID_CLIENT_SECRET=e2e-nuxt-secret" \ + "THUNDERID_SESSION_SECRET=${NUXT_SECRET}" \ + "NODE_TLS_REJECT_UNAUTHORIZED=0" + + - name: ๐Ÿš€ Start sample apps + run: | + ( cd samples/browser/quickstart && pnpm exec vite --port 5173 & ) + ( cd samples/react/quickstart && pnpm exec vite --port 5174 & ) + ( cd samples/vue/quickstart && pnpm exec vite --port 5175 & ) + ( cd samples/nextjs/quickstart && pnpm exec next dev -p 3001 & ) + ( cd samples/nuxt/quickstart && pnpm exec nuxt dev --port 3002 & ) + ( cd samples/express/quickstart && pnpm exec node --env-file-if-exists=.env index.mjs & ) + for url in http://localhost:5173 http://localhost:5174 http://localhost:5175 http://localhost:3000 http://localhost:3001 http://localhost:3002; do + ready=0 + for i in $(seq 1 60); do + curl -sf "$url" > /dev/null && ready=1 && break + sleep 2 + done + if [ "$ready" != "1" ]; then + echo "ERROR: $url did not become ready after 120s" && exit 1 + fi + done + + - name: ๐ŸŽญ Install Playwright browsers + working-directory: tests/e2e + run: npx playwright install --with-deps chromium + + - name: ๐Ÿงช Run E2E tests + working-directory: tests/e2e + env: + SERVER_URL: https://localhost:8090 + ADMIN_USERNAME: admin + ADMIN_PASSWORD: admin + ADMIN_TOKEN: ${{ steps.admin-token.outputs.token }} + TEST_USER_USERNAME: e2e-test-user + TEST_USER_PASSWORD: E2ePassword@123 + BROWSER_APP_URL: http://localhost:5173 + REACT_APP_URL: http://localhost:5174 + VUE_APP_URL: http://localhost:5175 + NEXTJS_APP_URL: http://localhost:3001 + NUXT_APP_URL: http://localhost:3002 + EXPRESS_APP_URL: http://localhost:3000 + # node/quickstart has no page to drive โ€” client-credentials.spec.ts spawns it directly + # with `env: process.env`, so its credentials just need to be here, not a .env file. + THUNDERID_BASE_URL: https://localhost:8090 + THUNDERID_CLIENT_ID: JS_SDK_E2E_NODE + THUNDERID_CLIENT_SECRET: e2e-node-secret + NODE_TLS_REJECT_UNAUTHORIZED: "0" + run: npx playwright test + + - name: ๐Ÿ“ค Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: playwright-report + path: tests/e2e/playwright-report/ + retention-days: 30 diff --git a/eslint.config.js b/eslint.config.js index 5719ab1..4eddf2d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,4 +14,12 @@ export default [ '@thunderid/copyright-header': 'off', }, }, + { + // Progress/setup/teardown output is the intended UX for E2E tooling, not stray debug + // logging โ€” same reasoning thunderid's own tests/e2e applies (no no-console rule there). + files: ['tests/e2e/**'], + rules: { + 'no-console': 'off', + }, + }, ]; diff --git a/package.json b/package.json index 4e5f0bb..e27705c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "devDependencies": { "turbo": "2.10.2", "@thunderid/eslint-plugin": "catalog:", + "@thunderid/prettier-config": "catalog:", "eslint": "catalog:", "prettier": "catalog:", "rimraf": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f75951..fb88b05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,7 +317,7 @@ overrides: brace-expansion@2: 2.1.4 brace-expansion@5: 5.0.9 js-yaml: 4.3.1 - nanoid@3: 3.3.17 + nanoid@3: 3.3.18 nanoid@5: 5.1.16 postcss: 8.5.23 sharp: 0.35.0 @@ -332,6 +332,9 @@ importers: '@thunderid/eslint-plugin': specifier: 'catalog:' version: 1.0.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.10)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0))) + '@thunderid/prettier-config': + specifier: 'catalog:' + version: 1.0.0 eslint: specifier: 'catalog:' version: 9.39.4(jiti@2.7.0) @@ -1019,6 +1022,39 @@ importers: specifier: ^6.3.5 version: 6.4.3(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.48.0)(yaml@2.9.0) + tests/e2e: + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.60.0 + '@thunderid/eslint-plugin': + specifier: 'catalog:' + version: 1.0.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.10)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0))) + '@thunderid/prettier-config': + specifier: 'catalog:' + version: 1.0.0 + '@types/node': + specifier: 'catalog:' + version: 24.7.2 + dotenv: + specifier: 17.2.3 + version: 17.2.3 + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.7.0) + prettier: + specifier: 'catalog:' + version: 3.6.2 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + undici: + specifier: ^7.0.0 + version: 7.29.0 + packages: '@asamuzakjp/css-color@4.1.2': @@ -4674,6 +4710,10 @@ packages: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} engines: {node: '>=20'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -5932,8 +5972,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -7419,6 +7459,10 @@ packages: undici-types@7.14.0: resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} @@ -12156,6 +12200,8 @@ snapshots: dependencies: type-fest: 5.7.0 + dotenv@17.2.3: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -13651,7 +13697,7 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} nanotar@0.3.0: {} @@ -14686,7 +14732,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -15662,6 +15708,8 @@ snapshots: undici-types@7.14.0: {} + undici@7.29.0: {} + undici@8.10.0: {} unenv@2.0.0-rc.24: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d45dab1..70c6d81 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - samples/*/* + - tests/* allowBuilds: '@parcel/watcher': true @@ -38,7 +39,7 @@ overrides: # packages/nextjs's `next` > `postcss` > `nanoid` (next still pins `postcss@8.4.31`, which pulls # an old nanoid โ€” see the `postcss` override below), and v5 via packages/nuxt's # `@nuxt/devtools` > `@vue/devtools-core` > `nanoid`. - nanoid@3: 3.3.17 + nanoid@3: 3.3.18 nanoid@5: 5.1.16 # JUSTIFICATION: Fixes GHSA-qx2v-qp2m-jg93 (XSS via unescaped `` in stringify output), # GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849 / GHSA-fxqj-rqcc-2cmp (arbitrary file read via diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example new file mode 100644 index 0000000..c24fa05 --- /dev/null +++ b/tests/e2e/.env.example @@ -0,0 +1,18 @@ +# Local overrides for the E2E suite. +# +# You normally do NOT need to create this file: run-e2e.sh auto-generates a working .env from +# defaults.env on first run. Copy this file to .env only if you want to override specific values +# (e.g. pointing at an already-running backend, or different sample app ports). +# +# SERVER_URL=https://localhost:8090 +# ADMIN_USERNAME=admin +# ADMIN_PASSWORD=admin +# ADMIN_TOKEN= +# TEST_USER_USERNAME=e2e-test-user +# TEST_USER_PASSWORD=E2ePassword@123 +# BROWSER_APP_URL=http://localhost:5173 +# REACT_APP_URL=http://localhost:5174 +# VUE_APP_URL=http://localhost:5175 +# NEXTJS_APP_URL=http://localhost:3001 +# NUXT_APP_URL=http://localhost:3002 +# EXPRESS_APP_URL=http://localhost:3000 diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..961cd67 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,8 @@ +# Downloaded by run-e2e.sh (npx thunderid) โ€” a full release distribution, not source. +thunderid/ +.npx-thunderid-home/ + +# Playwright output +playwright-report/ +test-results/ +blob-report/ diff --git a/tests/e2e/.prettierignore b/tests/e2e/.prettierignore new file mode 100644 index 0000000..14fc5e1 --- /dev/null +++ b/tests/e2e/.prettierignore @@ -0,0 +1,3 @@ +# Go-template + YAML hybrids (ThunderID's declarative /import format) โ€” not valid YAML on their +# own, only once the {{ }} placeholders are rendered server-side. +thunderid-config/**/*.yaml diff --git a/tests/e2e/constants/sample-apps.ts b/tests/e2e/constants/sample-apps.ts new file mode 100644 index 0000000..4a83d05 --- /dev/null +++ b/tests/e2e/constants/sample-apps.ts @@ -0,0 +1,78 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Sample App Registry + * + * One entry per browser-testable quickstart app under `samples/`, each with a dedicated port (so + * all apps can run concurrently against a single backend) and a dedicated OAuth2 client (declared + * with matching redirectUris in `thunderid-config/sample-apps.yaml`) so one app's E2E run never + * collides with another's. `node/quickstart` is deliberately absent from this registry โ€” it has + * no browser UI, so no page object or port โ€” but it still has its own OAuth2 client, see + * `NodeQuickstart` below and `tests/node-quickstart/client-credentials.spec.ts`. + * + * Ports match each dev server's own default where possible (browser/quickstart keeps Vite's 5173, + * express/quickstart keeps its hardcoded 3000) and are staggered elsewhere via each tool's own + * `--port`/`-p` flag โ€” no sample app source changes required. + */ +export const SampleApps = { + BROWSER: { + clientId: 'JS_SDK_E2E_BROWSER', + dir: 'samples/browser/quickstart', + envVar: 'BROWSER_APP_URL', + name: 'browser/quickstart', + port: 5173, + }, + EXPRESS: { + clientId: 'JS_SDK_E2E_EXPRESS', + dir: 'samples/express/quickstart', + envVar: 'EXPRESS_APP_URL', + name: 'express/quickstart', + port: 3000, + }, + NEXTJS: { + clientId: 'JS_SDK_E2E_NEXTJS', + dir: 'samples/nextjs/quickstart', + envVar: 'NEXTJS_APP_URL', + name: 'nextjs/quickstart', + port: 3001, + }, + NUXT: { + clientId: 'JS_SDK_E2E_NUXT', + dir: 'samples/nuxt/quickstart', + envVar: 'NUXT_APP_URL', + name: 'nuxt/quickstart', + port: 3002, + }, + REACT: { + clientId: 'JS_SDK_E2E_REACT', + dir: 'samples/react/quickstart', + envVar: 'REACT_APP_URL', + name: 'react/quickstart', + port: 5174, + }, + VUE: { + clientId: 'JS_SDK_E2E_VUE', + dir: 'samples/vue/quickstart', + envVar: 'VUE_APP_URL', + name: 'vue/quickstart', + port: 5175, + }, +} as const; + +export type SampleAppKey = keyof typeof SampleApps; + +/** + * The one non-browser sample under E2E test: no page, no port, authenticates itself via + * client_credentials (see AuthService.mjs) rather than signing a user in. + */ +export const NodeQuickstart = { + clientId: 'JS_SDK_E2E_NODE', + dir: 'samples/node/quickstart', + name: 'node/quickstart', +} as const; + +/** Resolves an app's base URL from its env var, falling back to `http://localhost:`. */ +export function sampleAppUrl(app: (typeof SampleApps)[SampleAppKey]): string { + return process.env[app.envVar] ?? `http://localhost:${app.port}`; +} diff --git a/tests/e2e/constants/timeouts.ts b/tests/e2e/constants/timeouts.ts new file mode 100644 index 0000000..c4a7268 --- /dev/null +++ b/tests/e2e/constants/timeouts.ts @@ -0,0 +1,22 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Global timeouts for the E2E suite. + */ +export const Timeouts = { + /** Default timeout for UI actions (clicks, fills). */ + DEFAULT_ACTION: 15000, + + /** Timeout for checking element visibility. */ + ELEMENT_VISIBILITY: 10000, + + /** Timeout for a redirect-flow round trip (app -> gate -> app). */ + REDIRECT: 20000, + + /** Budget for a suite-level beforeAll/afterAll that provisions server state (e.g. a test user). */ + SUITE_SETUP: 60 * 1000, + + /** Per-test timeout (playwright.config.ts). */ + GLOBAL_TEST: 60 * 1000, +} as const; diff --git a/tests/e2e/defaults.env b/tests/e2e/defaults.env new file mode 100644 index 0000000..79b9204 --- /dev/null +++ b/tests/e2e/defaults.env @@ -0,0 +1,18 @@ +# Canonical fixed dataset for running the E2E suite unattended (run-e2e.sh and CI both load this +# file). Override any of these via a real .env file locally, or via repository Secrets/Variables +# in CI. +SERVER_URL=https://localhost:8090 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin +TEST_USER_USERNAME=e2e-test-user +TEST_USER_PASSWORD=E2ePassword@123 + +# One dedicated port per sample app โ€” see constants/sample-apps.ts, which is the source of truth +# these must match (both the ports here and the OAuth2 redirectUris declared for each app in +# thunderid-config/sample-apps.yaml). +BROWSER_APP_URL=http://localhost:5173 +REACT_APP_URL=http://localhost:5174 +VUE_APP_URL=http://localhost:5175 +NEXTJS_APP_URL=http://localhost:3001 +NUXT_APP_URL=http://localhost:3002 +EXPRESS_APP_URL=http://localhost:3000 diff --git a/tests/e2e/fixtures/sample-apps/index.ts b/tests/e2e/fixtures/sample-apps/index.ts new file mode 100644 index 0000000..af71e4c --- /dev/null +++ b/tests/e2e/fixtures/sample-apps/index.ts @@ -0,0 +1,45 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Sample App Fixtures + * + * One page-object fixture per sample app. Each test file only pulls in the one fixture it needs. + */ + +import {test as base} from '@playwright/test'; +import {BrowserQuickstartPage} from '../../pages/browser-quickstart.page'; +import {ExpressQuickstartPage} from '../../pages/express-quickstart.page'; +import {ThunderIDWebSamplePage} from '../../pages/thunderid-web-sample.page'; + +interface SampleAppFixtures { + browserQuickstartPage: BrowserQuickstartPage; + expressQuickstartPage: ExpressQuickstartPage; + nextjsQuickstartPage: ThunderIDWebSamplePage; + nuxtQuickstartPage: ThunderIDWebSamplePage; + reactQuickstartPage: ThunderIDWebSamplePage; + vueQuickstartPage: ThunderIDWebSamplePage; +} + +export const test = base.extend({ + browserQuickstartPage: async ({page}, use) => { + await use(new BrowserQuickstartPage(page)); + }, + expressQuickstartPage: async ({page}, use) => { + await use(new ExpressQuickstartPage(page)); + }, + nextjsQuickstartPage: async ({page}, use) => { + await use(new ThunderIDWebSamplePage(page)); + }, + nuxtQuickstartPage: async ({page}, use) => { + await use(new ThunderIDWebSamplePage(page)); + }, + reactQuickstartPage: async ({page}, use) => { + await use(new ThunderIDWebSamplePage(page)); + }, + vueQuickstartPage: async ({page}, use) => { + await use(new ThunderIDWebSamplePage(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..208a20b --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,42 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Global Setup + * + * Runs once before the whole suite. Creates the one shared E2E test user every sample app spec + * signs in as (see constants/timeouts.ts's SUITE_SETUP budget) โ€” the OAuth *clients* are + * per-app (thunderid-config/sample-apps.yaml), but there's no reason to provision a separate user + * per app for the same identity signing into different client apps. + * + * Modeled on thunderid/tests/e2e/global-setup.ts. + */ + +import path from 'node:path'; +import dotenv from 'dotenv'; +import {createUser} from './utils/users-api'; + +async function globalSetup(): Promise { + dotenv.config({path: path.resolve(import.meta.dirname, '.env')}); + dotenv.config({path: path.resolve(import.meta.dirname, 'defaults.env')}); + + const required = ['SERVER_URL', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'TEST_USER_USERNAME', 'TEST_USER_PASSWORD']; + const missing = required.filter((name) => !process.env[name]); + if (missing.length > 0) { + console.error(`โŒ Missing required environment variables: ${missing.join(', ')}`); + console.error('Copy defaults.env to .env, or export these before running the suite.'); + process.exit(1); + } + + console.log('๐Ÿš€ Creating shared E2E test user...'); + const user = await createUser({ + email: `${process.env.TEST_USER_USERNAME}@example.com`, + family_name: 'E2E', + given_name: 'Test', + password: process.env.TEST_USER_PASSWORD, + username: process.env.TEST_USER_USERNAME, + }); + console.log(`โœ“ Test user ready: ${user.id}`); +} + +export default globalSetup; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000..4fc0c15 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,40 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Global Teardown + * + * Deletes the shared E2E test user created in global-setup.ts. Looked up by username rather than + * carried over in memory โ€” global-setup and global-teardown run in separate processes. + */ + +import {send} from './utils/api-request'; + +async function globalTeardown(): Promise { + const username = process.env.TEST_USER_USERNAME; + if (!username) return; + + console.log('๐Ÿงน Deleting shared E2E test user...'); + const searchRes = await send('GET', `/users?attribute=username&value=${encodeURIComponent(username)}`); + if (!searchRes.ok) { + console.warn(`โš ๏ธ Could not look up test user "${username}" for cleanup (HTTP ${searchRes.status})`); + return; + } + const {users} = (await searchRes.json()) as {users?: {id: string}[]}; + const user = users?.[0]; + if (!user) { + console.warn(`โš ๏ธ Test user "${username}" not found โ€” nothing to clean up`); + return; + } + + const deleteRes = await send('DELETE', `/users/${user.id}`); + if (!deleteRes.ok) { + // A leaked user breaks the *next* run outright โ€” global-setup's createUser call hits a + // username conflict with no clue why โ€” so fail loudly here, at the point the leak actually + // happens, instead of letting it surface as a confusing failure days later. + throw new Error(`Failed to delete test user ${user.id}: HTTP ${deleteRes.status}: ${await deleteRes.text()}`); + } + console.log(`โœ“ Test user deleted: ${user.id}`); +} + +export default globalTeardown; diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..8a25fbe --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,33 @@ +{ + "name": "@thunderid/e2e", + "private": true, + "version": "0.0.0", + "description": "End-to-end tests driving the sample quickstart apps against a real ThunderID backend", + "type": "module", + "scripts": { + "clean-reports": "rimraf playwright-report test-results blob-report", + "test:e2e": "pnpm run clean-reports && playwright test", + "test:chromium": "playwright test --project=chromium", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "ui": "playwright test --ui", + "report": "playwright show-report", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format:check": "prettier --check --cache .", + "format:fix": "prettier --write --cache .", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@thunderid/eslint-plugin": "catalog:", + "@thunderid/prettier-config": "catalog:", + "@types/node": "catalog:", + "dotenv": "17.2.3", + "eslint": "catalog:", + "prettier": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "undici": "^7.0.0" + } +} diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts new file mode 100644 index 0000000..5cfada1 --- /dev/null +++ b/tests/e2e/pages/base.page.ts @@ -0,0 +1,19 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import path from 'node:path'; +import {Page} from '@playwright/test'; + +/** + * Base Page Object Model โ€” shared screenshot helper for debugging. + * + * Modeled on thunderid/tests/e2e/pages/base.page.ts. + */ +export class BasePage { + constructor(readonly page: Page) {} + + async screenshot(name: string): Promise { + const screenshotPath = path.resolve(import.meta.dirname, '../test-results/debug', `${name}.png`); + await this.page.screenshot({fullPage: true, path: screenshotPath}); + } +} diff --git a/tests/e2e/pages/browser-quickstart.page.ts b/tests/e2e/pages/browser-quickstart.page.ts new file mode 100644 index 0000000..675080a --- /dev/null +++ b/tests/e2e/pages/browser-quickstart.page.ts @@ -0,0 +1,90 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * browser/quickstart Page Object โ€” vanilla JS + @thunderid/browser, redirect flow. + * Custom hand-built markup (not the shared component library), so its own selectors: see + * samples/browser/quickstart/src/components/nav.js and src/pages/home.js. + */ + +import {Page, expect} from '@playwright/test'; +import {GateLoginPage} from './gate-login.page'; +import {Timeouts} from '../constants/timeouts'; + +export class BrowserQuickstartPage extends GateLoginPage { + constructor(page: Page) { + super(page); + } + + async goto(url: string): Promise { + await this.page.goto(url, {waitUntil: 'commit'}); + } + + async verifyHomePageLoaded(): Promise { + await expect(this.page.locator('#hero-sign-in-btn, #sign-in-btn').first()).toBeVisible({ + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + async clickSignInButton(): Promise { + await this.page.locator('#hero-sign-in-btn, #sign-in-btn').first().click(); + } + + async verifyLoggedIn(): Promise { + await expect(this.page.locator('#ud-trigger')).toBeVisible({timeout: Timeouts.REDIRECT}); + } + + async verifyLoggedOut(): Promise { + await this.verifyHomePageLoaded(); + } + + async logout(): Promise { + await this.page.locator('#ud-trigger').click(); + await this.page.locator('#ud-sign-out').click(); + await this.confirmSignOutIfPrompted(); + } + + /** Opens the token debug page via the user dropdown (src/pages/token.js โ€” an in-memory SPA + * "route" with no real URL change, so callers must wait for its content, not a URL). */ + async openTokenDebug(): Promise { + await this.page.locator('#ud-trigger').click(); + await this.page.locator('#ud-token-debug').click(); + } + + async verifyTokenDebugLoaded(): Promise { + await expect(this.page.locator('.token-main')).toBeVisible({timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Reads the raw access token JWT rendered across the three .token-part--* spans. */ + async getDisplayedAccessToken(): Promise { + const raw = this.page.locator('.token-raw'); + await raw.waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + const header = await raw.locator('.token-part--header').innerText(); + const payload = await raw.locator('.token-part--payload').innerText(); + const signature = await raw.locator('.token-part--signature').innerText(); + return `${header}.${payload}.${signature}`; + } + + /** Opens the "Manage Profile" dialog (src/components/profileDialog.js). */ + async openManageProfile(): Promise { + await this.page.locator('#ud-trigger').click(); + await this.page.locator('#ud-manage-profile').click(); + await this.page + .locator('#profile-dialog-overlay') + .waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + async fillProfileName(givenName: string, familyName: string): Promise { + await this.page.locator('#profile-first-name').fill(givenName); + await this.page.locator('#profile-last-name').fill(familyName); + } + + async saveProfile(): Promise { + await this.page.locator('#profile-dialog-save').click(); + await this.page.locator('#profile-dialog-overlay').waitFor({state: 'hidden', timeout: Timeouts.DEFAULT_ACTION}); + } + + async verifyDisplayedName(fullName: string): Promise { + await expect(this.page.locator('.ud-name')).toHaveText(fullName, {timeout: Timeouts.ELEMENT_VISIBILITY}); + } +} diff --git a/tests/e2e/pages/express-quickstart.page.ts b/tests/e2e/pages/express-quickstart.page.ts new file mode 100644 index 0000000..7245801 --- /dev/null +++ b/tests/e2e/pages/express-quickstart.page.ts @@ -0,0 +1,67 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * express/quickstart Page Object โ€” server-rendered Express app + @thunderid/express, cookie + * session. No client SDK, plain server-rendered markup (a native
dropdown), so its own + * selectors: see samples/express/quickstart/lib/layout.mjs. + */ + +import {Page, expect} from '@playwright/test'; +import {GateLoginPage} from './gate-login.page'; +import {Timeouts} from '../constants/timeouts'; + +export class ExpressQuickstartPage extends GateLoginPage { + constructor(page: Page) { + super(page); + } + + async goto(url: string): Promise { + await this.page.goto(url, {waitUntil: 'commit'}); + } + + async verifyHomePageLoaded(): Promise { + await expect(this.page.locator('a.btn-primary[href="/login"]')).toBeVisible({ + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + async clickSignInButton(): Promise { + await this.page.locator('a.btn-primary[href="/login"]').click(); + } + + async verifyLoggedIn(): Promise { + await expect(this.page.locator('.user-menu-trigger')).toBeVisible({timeout: Timeouts.REDIRECT}); + } + + async verifyLoggedOut(): Promise { + await this.verifyHomePageLoaded(); + } + + async logout(): Promise { + await this.page.locator('.user-menu-trigger').click(); + await this.page.locator('a.user-menu-item[href="/logout"]').click(); + await this.confirmSignOutIfPrompted(); + } + + /** Opens the token debug page via the user menu's "Token debug" link (see lib/layout.mjs). */ + async openTokenDebug(): Promise { + await this.page.locator('.user-menu-trigger').click(); + await this.page.locator('a.user-menu-item[href="/token"]').click(); + } + + async verifyTokenDebugLoaded(): Promise { + await expect(this.page.locator('.token-raw')).toBeVisible({timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Reads the raw access token JWT rendered across the three .token-part--* spans โ€” see + * samples/express/quickstart/index.mjs's /token handler. */ + async getDisplayedAccessToken(): Promise { + const raw = this.page.locator('.token-raw'); + await raw.waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + const header = await raw.locator('.token-part--header').innerText(); + const payload = await raw.locator('.token-part--payload').innerText(); + const signature = await raw.locator('.token-part--signature').innerText(); + return `${header}.${payload}.${signature}`; + } +} diff --git a/tests/e2e/pages/gate-login.page.ts b/tests/e2e/pages/gate-login.page.ts new file mode 100644 index 0000000..21ec9bd --- /dev/null +++ b/tests/e2e/pages/gate-login.page.ts @@ -0,0 +1,73 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Gate Login Page Object + * + * The OAuth authorization_code redirect flow always lands on ThunderID's shared "gate" app, + * which renders the login form and sign-out confirmation dynamically from the client + * application's auth flow. That DOM is identical regardless of which sample app (browser, react, + * vue, nextjs, nuxt, express) initiated the redirect, so the methods here are shared across every + * sample-app page object rather than duplicated per app. Only each app's own home-page chrome + * (its sign-in button, its post-login landing content) is app-specific and lives in the subclass. + * + * Modeled directly on thunderid/tests/e2e/pages/gate-login.page.ts โ€” same gate, same DOM. + */ + +import {Page, expect} from '@playwright/test'; +import {BasePage} from './base.page'; +import {Timeouts} from '../constants/timeouts'; + +export class GateLoginPage extends BasePage { + constructor(page: Page) { + super(page); + } + + async verifyLoginPageLoaded(): Promise { + await this.page.waitForSelector('input[name="username"], input[placeholder*="username" i]', { + state: 'visible', + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + async fillLoginForm(username: string, password: string): Promise { + const usernameInput = this.page.locator('input[name="username"], input[placeholder*="username" i]').first(); + await usernameInput.waitFor({state: 'visible', timeout: Timeouts.DEFAULT_ACTION}); + await usernameInput.fill(username); + + const passwordInput = this.page.locator('input[name="password"], input[placeholder*="password" i]').first(); + await passwordInput.waitFor({state: 'visible', timeout: Timeouts.DEFAULT_ACTION}); + await passwordInput.fill(password); + } + + async clickLogin(): Promise { + const loginButton = this.page + .locator('button[type="submit"], button:has-text("Sign In"), button:has-text("Sign in")') + .first(); + await loginButton.waitFor({state: 'visible', timeout: Timeouts.DEFAULT_ACTION}); + await loginButton.click(); + } + + /** Callers verify the result with their own signed-in check, so no wait beyond the click. */ + async login(username: string, password: string): Promise { + await this.fillLoginForm(username, password); + await this.clickLogin(); + } + + /** + * Click through the gate's sign-out confirmation, if the app's sign-out button doesn't already + * complete RP-initiated logout in one step. Callers verify the result themselves. + */ + async confirmSignOutIfPrompted(): Promise { + const confirmButton = this.page.getByRole('button', {exact: true, name: 'Sign out'}); + const shown = await confirmButton + .waitFor({state: 'visible', timeout: Timeouts.DEFAULT_ACTION}) + .then(() => true) + .catch(() => false); + if (shown) { + await confirmButton.click(); + } + } +} + +export {expect}; diff --git a/tests/e2e/pages/thunderid-web-sample.page.ts b/tests/e2e/pages/thunderid-web-sample.page.ts new file mode 100644 index 0000000..021ba76 --- /dev/null +++ b/tests/e2e/pages/thunderid-web-sample.page.ts @@ -0,0 +1,130 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * ThunderID Web Sample Page Object + * + * react/quickstart, vue/quickstart, nextjs/quickstart, and nuxt/quickstart all render the same + * `@thunderid/{react,vue}`-family components with the same markup: a `button.btn-primary` + * "Sign in" CTA, and a `UserDropdown` trigger carrying either `data-testid= + * "thunderid-user-dropdown-trigger"` (react and, since it wraps react, nextjs) or the vendor- + * prefixed `user-dropdown__trigger` class (vue and, since it wraps vue, nuxt) โ€” see + * packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx and + * packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts. One Page Object + * covers all four apps; only the base URL differs (see fixtures/sample-apps). + */ + +import {Page, expect} from '@playwright/test'; +import {GateLoginPage} from './gate-login.page'; +import {Timeouts} from '../constants/timeouts'; + +const USER_DROPDOWN_TRIGGER = + 'button[data-testid="thunderid-user-dropdown-trigger"], button[class*="user-dropdown__trigger"]'; + +/** Profile field labels, matching the schema's configured displayName โ€” see + * editProfileField's doc comment. */ +export const ProfileFieldLabels = { + familyName: /^Last Name$/, + givenName: /^First Name$/, +}; + +export class ThunderIDWebSamplePage extends GateLoginPage { + constructor(page: Page) { + super(page); + } + + async goto(url: string): Promise { + // 'load' alone isn't enough for nextjs/nuxt: both server-render this page, so the sign-in + // button is visible (and Playwright-clickable) before client-side hydration attaches its + // handler, making an early click a silent no-op. 'networkidle' waits out the trailing + // hydration-related requests (Nuxt DevTools, etc.) that follow the load event. + await this.page.goto(url, {waitUntil: 'networkidle'}); + } + + async verifyHomePageLoaded(): Promise { + await expect(this.page.locator('button.btn-primary', {hasText: 'Sign in'}).first()).toBeVisible({ + timeout: Timeouts.ELEMENT_VISIBILITY, + }); + } + + async clickSignInButton(): Promise { + await this.page.locator('button.btn-primary', {hasText: 'Sign in'}).first().click(); + } + + async verifyLoggedIn(): Promise { + await expect(this.page.locator(USER_DROPDOWN_TRIGGER).first()).toBeVisible({timeout: Timeouts.REDIRECT}); + } + + async verifyLoggedOut(): Promise { + await this.verifyHomePageLoaded(); + } + + async logout(): Promise { + await this.page.locator(USER_DROPDOWN_TRIGGER).first().click(); + await this.page.getByRole('button', {name: 'Sign Out'}).click(); + await this.confirmSignOutIfPrompted(); + } + + /** Opens the token debug page via the "Token debug" menu item each sample adds to + * `UserDropdown`'s `menuItems` prop. React/Next.js render it as a real `/token` link; Vue/Nuxt + * wire it to an `onClick` page-switch instead (no real navigation) โ€” so this matches on text + * rather than a specific role. */ + async openTokenDebug(): Promise { + await this.page.locator(USER_DROPDOWN_TRIGGER).first().click(); + await this.page.getByText('Token debug', {exact: true}).click(); + } + + async verifyTokenDebugLoaded(): Promise { + await expect(this.page.locator('.token-raw')).toBeVisible({timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Reads the raw access token JWT rendered across the three .token-part--* spans โ€” the same + * class names browser/quickstart's own hand-written token.js uses, since every sample's + * TokenDebugPage is its own component, not part of the shared UI library. */ + async getDisplayedAccessToken(): Promise { + const raw = this.page.locator('.token-raw'); + await raw.waitFor({state: 'visible', timeout: Timeouts.ELEMENT_VISIBILITY}); + const header = await raw.locator('.token-part--header').innerText(); + const payload = await raw.locator('.token-part--payload').innerText(); + const signature = await raw.locator('.token-part--signature').innerText(); + return `${header}.${payload}.${signature}`; + } + + /** Opens the SDK-provided profile dialog โ€” `UserDropdown`'s built-in profile action, present + * (and always on, no opt-in prop needed) in both the React and Vue packages, just under + * different labels: React's wrapped `UserDropdown` hardcodes "Manage Profile" + * (BaseUserDropdown.tsx's `handleManageProfile`); Vue's hardcodes plain "Profile" + * (BaseUserDropdown.ts:359, `onProfileClick`/`profileContent`). Nuxt inherits Vue's via its own + * `UserDropdown` wrapper, which delegates to the same `@thunderid/vue` component. */ + async openManageProfile(): Promise { + await this.page.locator(USER_DROPDOWN_TRIGGER).first().click(); + await this.page.getByRole('button', {name: /^(Manage Profile|Profile)$/}).click(); + await expect(this.page.getByRole('dialog')).toBeVisible({timeout: Timeouts.ELEMENT_VISIBILITY}); + } + + /** Edits one field of the profile form, which renders each attribute as its own row with an + * "Edit" button that reveals an inline input plus row-scoped Save/Cancel buttons (not one big + * form with a single submit) โ€” see packages/react/.../BaseUserProfile.tsx and its Vue + * equivalent. `label` matches the schema attribute's configured displayName (e.g. "First + * Name"), which BaseUserProfile renders once it receives the app's `userSchema` (fetched from + * `/users/me/meta`). */ + async editProfileField(label: RegExp, value: string): Promise { + const row = this.page.getByRole('dialog').getByText(label).locator('../..'); + await row.getByRole('button', {name: 'Edit'}).click(); + await row.locator('input').fill(value); + await row.getByRole('button', {name: 'Save'}).click(); + await expect(row.locator('input')).toHaveCount(0, {timeout: Timeouts.DEFAULT_ACTION}); + } + + async closeManageProfile(): Promise { + await this.page.getByRole('dialog').getByRole('button', {name: 'Close'}).click(); + } + + /** Verifies a field's row reverted from edit mode back to display mode showing the just-saved + * value โ€” checked in place rather than via the dropdown trigger or homepage, since whether + * those pick up the change without a session refresh isn't guaranteed. */ + async verifyProfileFieldValue(label: RegExp, value: string): Promise { + const row = this.page.getByRole('dialog').getByText(label).locator('../..'); + await expect(row).toContainText(value, {timeout: Timeouts.ELEMENT_VISIBILITY}); + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..bebfb3c --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,59 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Playwright E2E Test Configuration + * + * Chromium only for this first pass (keep CI time down โ€” see the label-gated `e2e` job in + * pr-builder.yml); a firefox/webkit matrix can follow the same shape thunderid's own + * playwright.config.ts uses once this suite's proven out. + * + * Backend + sample app provisioning happens externally (run-e2e.sh locally, the `e2e` CI job in + * pr-builder.yml) โ€” not via Playwright's own `webServer` option โ€” because it's a shared backend + * plus six independently-ported sample apps, not a single dev server. + */ + +import path from 'node:path'; +import {defineConfig, devices} from '@playwright/test'; +import dotenv from 'dotenv'; +import {Timeouts} from './constants/timeouts'; + +// .env (if present) takes priority; defaults.env fills in anything it doesn't set, so running +// Playwright directly (without run-e2e.sh, which copies defaults.env to .env itself) still works +// against a locally-managed backend on the default port instead of failing on missing config. +dotenv.config({path: path.resolve(import.meta.dirname, '.env')}); +dotenv.config({path: path.resolve(import.meta.dirname, 'defaults.env')}); + +export default defineConfig({ + testDir: './tests', + + // Each spec drives a distinct app/port with its own OAuth client, so cross-file parallelism is + // safe; keep it off within a file (Playwright's default) so TC001/TC002 in the same describe + // block never race the same browser context. + fullyParallel: true, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 4 : undefined, + + reporter: [['list'], ['html', {open: 'never'}]], + + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + + timeout: Timeouts.GLOBAL_TEST, + + use: { + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + ], +}); diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh new file mode 100755 index 0000000..eea9433 --- /dev/null +++ b/tests/e2e/run-e2e.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# Copyright 2025 The ThunderID Authors +# SPDX-License-Identifier: Apache-2.0 +# +# run-e2e.sh - Local E2E test runner for javascript-sdks. +# +# Downloads the latest ThunderID release distribution (this repo can't build the backend from +# source โ€” that lives in thunder-id/thunderid), bootstraps it, imports each sample app's OAuth2 +# client declaratively, builds the SDK packages under test, starts all six sample apps on their +# assigned ports, and runs the Playwright suite. Mirrors thunder-id/thunderid/tests/e2e/run-e2e.sh +# as closely as a cross-repo setup allows โ€” same setup.sh/start.sh/health-check/import sequence. +# +# Usage: +# ./run-e2e.sh [playwright-args...] +# +# Requirements: curl, jq, unzip, pnpm, lsof + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +DIST_HOME="" +SERVER_PORT=8090 + +ADMIN_USER="${ADMIN_USERNAME:-admin}" +ADMIN_PASS="${ADMIN_PASSWORD:-admin}" +ADMIN_TOKEN="" +APP_ENVS_WRITTEN=0 + +kill_port() { + lsof -ti tcp:"$1" | xargs kill -9 2>/dev/null || true +} + +wait_for_url() { + local url="$1" label="$2" i=0 + echo "Waiting for $label at $url..." + while [ $i -lt 60 ]; do + if curl -skf "$url" > /dev/null 2>&1; then + echo "$label is ready." + return 0 + fi + i=$((i + 1)) + sleep 2 + done + echo "ERROR: $label did not become ready after 120s." + return 1 +} + +cleanup() { + echo "Cleaning up..." + kill_port "$SERVER_PORT" + kill_port 5173 + kill_port 5174 + kill_port 5175 + kill_port 3000 + kill_port 3001 + kill_port 3002 + # restore_app_envs falls back to deleting each app's .env when no .e2e-backup marker exists โ€” + # correct once write_app_envs has actually run (it backs up first), wrong on any earlier exit + # (e.g. the already-running-server check below), which would otherwise wipe a developer's own + # untouched .env files that this script never wrote in the first place. + if [ "$APP_ENVS_WRITTEN" = 1 ]; then + restore_app_envs + fi + rm -rf "$SCRIPT_DIR/thunderid" "$SCRIPT_DIR/.npx-thunderid-home" "$SCRIPT_DIR/distribution" +} +trap cleanup EXIT + +# Sample app dirs whose .env this script owns for the run (see write_app_envs/restore_app_envs). +APP_ENV_DIRS=( + "samples/browser/quickstart" + "samples/react/quickstart" + "samples/vue/quickstart" + "samples/nextjs/quickstart" + "samples/nuxt/quickstart" + "samples/express/quickstart" +) + +# Backs up any .env a developer already has in a sample app dir (from their own manual testing) +# before this script overwrites it, so cleanup can restore it rather than leaving a real E2E +# client secret sitting in a dev's working tree. +backup_app_env() { + local dir="$PROJECT_ROOT/$1/.env" + [ -f "$dir" ] && [ ! -f "$dir.e2e-backup" ] && mv "$dir" "$dir.e2e-backup" + return 0 +} + +restore_app_envs() { + local app_dir env_file + for app_dir in "${APP_ENV_DIRS[@]}"; do + env_file="$PROJECT_ROOT/$app_dir/.env" + if [ -f "$env_file.e2e-backup" ]; then + mv "$env_file.e2e-backup" "$env_file" + else + rm -f "$env_file" + fi + done +} + +# Resolves the release asset name for the current platform, e.g. thunderid-1.0.0-beta2-macos-arm64. +# Only used by the THUNDERID_VERSION debug-pin fallback below: npx thunderid detects the platform +# itself for the normal, always-latest path. +resolve_platform() { + local os arch + case "$(uname -s)" in + Darwin) os="macos" ;; + Linux) os="linux" ;; + *) echo "ERROR: Unsupported OS $(uname -s)"; exit 1 ;; + esac + case "$(uname -m)" in + arm64|aarch64) arch="arm64" ;; + x86_64) arch="x64" ;; + *) echo "ERROR: Unsupported architecture $(uname -m)"; exit 1 ;; + esac + echo "${os}-${arch}" +} + +# Installs, sets up, and starts a ThunderID server. +# +# The normal path delegates entirely to `npx thunderid`: it resolves the latest release itself, no +# GitHub API or thunderid.dev calls of our own and no version pinned in this repo, downloads it for +# the current platform, and runs setup.sh non-interactively (admin credentials passed through via +# THUNDERID_ADMIN_* env vars). It always finishes by trying to attach its interactive REPL, which +# needs a real TTY and fails in a script; that is expected, so its exit code is ignored below. By +# the time it fails, the release is already downloaded and fully set up on disk, so we start it +# ourselves with `start.sh` rather than relying on npx thunderid's own (REPL-attached) background +# process, which does not outlive the npx invocation. A scratch HOME is used for that one +# invocation so it can't see, or reuse, a version already active from a developer's own separate +# `npx thunderid` use on this machine, guaranteeing a fresh "no active version" state and therefore +# the true latest release every run. +# +# THUNDERID_VERSION pins an exact version instead (e.g. to reproduce a specific nightly failure). +# npx thunderid has no flag to target an explicit non-latest version, so that path downloads the +# release zip directly. +download_and_start_server() { + if [ -n "${THUNDERID_VERSION:-}" ]; then + local platform dist_folder asset_name download_url + + platform=$(resolve_platform) + dist_folder="thunderid-${THUNDERID_VERSION}-${platform}" + asset_name="${dist_folder}.zip" + download_url="https://github.com/thunder-id/thunderid/releases/download/v${THUNDERID_VERSION}/${asset_name}" + + DIST_HOME="$SCRIPT_DIR/distribution" + rm -rf "$DIST_HOME" + mkdir -p "$DIST_HOME" + + echo "Downloading ThunderID ${THUNDERID_VERSION} (${platform})..." + curl -sL "$download_url" -o "$SCRIPT_DIR/${asset_name}" + unzip -q "$SCRIPT_DIR/${asset_name}" -d "$SCRIPT_DIR/distribution-tmp" + mv "$SCRIPT_DIR/distribution-tmp/${dist_folder}/"* "$DIST_HOME/" + rm -rf "$SCRIPT_DIR/distribution-tmp" "$SCRIPT_DIR/${asset_name}" + + echo "Running setup..." + (cd "$DIST_HOME" && chmod +x setup.sh start.sh && ./setup.sh --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PASS") + else + echo "Installing and setting up the latest ThunderID release via npx..." + rm -rf "$SCRIPT_DIR/thunderid" "$SCRIPT_DIR/.npx-thunderid-home" + mkdir -p "$SCRIPT_DIR/.npx-thunderid-home" + # Redirecting stdin to /dev/null isn't enough to stop npx thunderid's REPL from taking + # over: bubbletea opens /dev/tty directly, which exists and is fully usable regardless of + # this process's own stdin โ€” so when this script is run from an actual interactive + # terminal (the normal case), the REPL attaches for real and blocks forever waiting for + # keystrokes nothing ever sends. `perl -MPOSIX -e 'setsid()'` detaches the child into a new + # session with no controlling terminal at all, so /dev/tty has nothing to open and + # bubbletea fails immediately instead โ€” the same expected, ignored failure this function's + # comment above describes. perl ships on both macOS and Linux, unlike setsid(1), which + # macOS doesn't have. + (cd "$SCRIPT_DIR" && \ + HOME="$SCRIPT_DIR/.npx-thunderid-home" \ + THUNDERID_ADMIN_USERNAME="$ADMIN_USER" THUNDERID_ADMIN_PASSWORD="$ADMIN_PASS" \ + perl -MPOSIX -e 'POSIX::setsid(); open(STDIN, "<", "/dev/null"); exec(@ARGV) or die $!' \ + -- npx --yes thunderid --verbose) || true + + DIST_HOME=$(find "$SCRIPT_DIR/thunderid" -maxdepth 1 -type d -name 'v*' 2>/dev/null | head -1) + if [ -z "$DIST_HOME" ]; then + echo "ERROR: npx thunderid did not produce an installed ThunderID release under $SCRIPT_DIR/thunderid." + exit 1 + fi + chmod +x "$DIST_HOME/start.sh" + echo "Using ThunderID $(basename "$DIST_HOME") from $DIST_HOME" + fi + + echo "Starting ThunderID server..." + (cd "$DIST_HOME" && ./start.sh) & + wait_for_url "https://localhost:${SERVER_PORT}/health/liveness" "ThunderID server" +} + +# Mints an admin token via the CONSOLE app (OAuth2 authorization_code + PKCE) โ€” same sequence as +# utils/authentication/index.ts, duplicated here in bash because config import (below) needs a +# token before Playwright (and its TypeScript admin-token helper) ever starts. +mint_admin_token() { + echo "Obtaining admin token..." + local redirect_uri="https://localhost:${SERVER_PORT}/console" + local code_verifier code_challenge + code_verifier=$(openssl rand -hex 32 | cut -c1-43) + code_challenge=$(printf '%s' "$code_verifier" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + + local headers_file + headers_file=$(mktemp) + curl -sk -o /dev/null -D "$headers_file" \ + -G "https://localhost:${SERVER_PORT}/oauth2/authorize" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "scope=system" \ + --data-urlencode "resource=https://localhost:${SERVER_PORT}/mcp" \ + --data-urlencode "response_type=code" \ + --data-urlencode "code_challenge=$code_challenge" \ + --data-urlencode "code_challenge_method=S256" + + local location auth_id exec_id + location=$(grep -i "^location:" "$headers_file" | tr -d '\r' | sed 's/^[Ll]ocation: //') + rm -f "$headers_file" + auth_id=$(echo "$location" | sed -n 's/.*[?&]authId=\([^&]*\).*/\1/p') + exec_id=$(echo "$location" | sed -n 's/.*[?&]executionId=\([^&]*\).*/\1/p') + + if [ -z "$auth_id" ] || [ -z "$exec_id" ]; then + echo "ERROR: Failed to parse authId/executionId from authorize redirect." + exit 1 + fi + + local prompt_resp challenge_token + prompt_resp=$(curl -sk -X POST "https://localhost:${SERVER_PORT}/flow/execute" \ + -H "Content-Type: application/json" -d "{\"executionId\": \"$exec_id\"}") + challenge_token=$(echo "$prompt_resp" | jq -r '.challengeToken // empty') + [ -n "$challenge_token" ] || { echo "ERROR: Flow execution did not return a challenge token: $prompt_resp"; exit 1; } + + local flow_resp assertion + flow_resp=$(curl -sk -X POST "https://localhost:${SERVER_PORT}/flow/execute" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg executionId "$exec_id" --arg challengeToken "$challenge_token" \ + --arg username "$ADMIN_USER" --arg password "$ADMIN_PASS" --arg action "action_001" \ + '{executionId: $executionId, challengeToken: $challengeToken, inputs: {username: $username, password: $password}, action: $action}')") + assertion=$(echo "$flow_resp" | jq -r '.assertion // empty') + [ -n "$assertion" ] || { echo "ERROR: Admin authentication failed: $flow_resp"; exit 1; } + + local callback_resp auth_code + callback_resp=$(curl -sk -X POST "https://localhost:${SERVER_PORT}/oauth2/auth/callback" \ + -H "Content-Type: application/json" -d "{\"authId\": \"$auth_id\", \"assertion\": \"$assertion\"}") + auth_code=$(echo "$callback_resp" | jq -r '.redirect_uri // empty' | sed 's/.*[?&]code=\([^&]*\).*/\1/') + [ -n "$auth_code" ] || { echo "ERROR: OAuth2 callback did not return an authorization code: $callback_resp"; exit 1; } + + local token_resp + token_resp=$(curl -sk -X POST "https://localhost:${SERVER_PORT}/oauth2/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$auth_code" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "client_id=CONSOLE" \ + --data-urlencode "resource=https://localhost:${SERVER_PORT}/mcp" \ + --data-urlencode "code_verifier=$code_verifier") + ADMIN_TOKEN=$(echo "$token_resp" | jq -r '.access_token // empty') + [ -n "$ADMIN_TOKEN" ] || { echo "ERROR: Failed to obtain admin access token: $token_resp"; exit 1; } +} + +import_sample_apps_config() { + echo "Importing sample app OAuth2 clients..." + local content variables http_status response_file + content=$(jq -Rs . < "$SCRIPT_DIR/thunderid-config/sample-apps.yaml") + variables=$(jq -n '{ + BROWSER_CLIENT_ID: "JS_SDK_E2E_BROWSER", BROWSER_REDIRECT_URIS: ["http://localhost:5173"], + REACT_CLIENT_ID: "JS_SDK_E2E_REACT", REACT_REDIRECT_URIS: ["http://localhost:5174"], + VUE_CLIENT_ID: "JS_SDK_E2E_VUE", VUE_REDIRECT_URIS: ["http://localhost:5175"], + NEXTJS_CLIENT_ID: "JS_SDK_E2E_NEXTJS", NEXTJS_CLIENT_SECRET: "e2e-nextjs-secret", NEXTJS_REDIRECT_URIS: ["http://localhost:3001"], + NUXT_CLIENT_ID: "JS_SDK_E2E_NUXT", NUXT_CLIENT_SECRET: "e2e-nuxt-secret", NUXT_REDIRECT_URIS: ["http://localhost:3002/api/auth/callback"], NUXT_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3002/"], + EXPRESS_CLIENT_ID: "JS_SDK_E2E_EXPRESS", EXPRESS_CLIENT_SECRET: "e2e-express-secret", EXPRESS_REDIRECT_URIS: ["http://localhost:3000/login"], EXPRESS_POST_LOGOUT_REDIRECT_URIS: ["http://localhost:3000/logout"], + NODE_CLIENT_ID: "JS_SDK_E2E_NODE", NODE_CLIENT_SECRET: "e2e-node-secret", + ALL_ORIGINS: ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175", "http://localhost:3000", "http://localhost:3001", "http://localhost:3002"] + }') + response_file=$(mktemp) + http_status=$(curl -sk -o "$response_file" -w "%{http_code}" \ + -X POST "https://localhost:${SERVER_PORT}/import" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d "{\"content\": $content, \"variables\": $variables, \"options\": {\"upsert\": true}}") + + if [ "$http_status" != "200" ]; then + echo "ERROR: import returned HTTP $http_status:"; cat "$response_file"; echo ""; rm -f "$response_file"; exit 1 + fi + local failed_count + failed_count=$(jq -r '.summary.failed // 0' "$response_file") + if [ "$failed_count" != "0" ]; then + echo "ERROR: import had $failed_count failed resource(s):"; cat "$response_file"; echo ""; rm -f "$response_file"; exit 1 + fi + rm -f "$response_file" + echo " Imported sample app OAuth2 clients." +} + +# Writes each sample app's own .env with the real OAuth2 client it was just imported with (see +# import_sample_apps_config). The apps' own `prepare-dev.cjs --flow=redirect` helper looks like +# the natural fit here, but it isn't: it rewrites the sample's checked-in package.json +# (`workspace:*` -> `latest`, defeating the whole point of testing the PR's own SDK changes) and +# blanks out any value that looks like one of its own placeholders โ€” including the literal string +# `https://localhost:8090`, which happens to be our *real* server URL too. Writing each .env +# directly, with the exact variable names those scripts use for "redirect flow", sidesteps both. +write_app_envs() { + echo "Writing sample app .env files..." + APP_ENVS_WRITTEN=1 + local nextjs_secret nuxt_secret + nextjs_secret=$(openssl rand -base64 32) + nuxt_secret=$(openssl rand -base64 32) + + for app_dir in "${APP_ENV_DIRS[@]}"; do + backup_app_env "$app_dir" + done + + cat > "$PROJECT_ROOT/samples/browser/quickstart/.env" < "$PROJECT_ROOT/samples/react/quickstart/.env" < "$PROJECT_ROOT/samples/vue/quickstart/.env" < "$PROJECT_ROOT/samples/express/quickstart/.env" < "$PROJECT_ROOT/samples/nextjs/quickstart/.env" < "$PROJECT_ROOT/samples/nuxt/quickstart/.env" < /dev/null 2>&1; then + echo "A ThunderID server is already running at https://localhost:${SERVER_PORT}." + echo "Stop it before running this script, which needs to manage the server lifecycle." + exit 1 +fi + +download_and_start_server +mint_admin_token +import_sample_apps_config + +echo "Building SDK packages..." +( cd "$PROJECT_ROOT" && pnpm install --frozen-lockfile && pnpm build ) + +write_app_envs +start_sample_apps +setup_env + +echo "Running Playwright E2E tests..." +( cd "$SCRIPT_DIR" && pnpm install --frozen-lockfile && npx playwright test "$@" ) diff --git a/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..a3665e8 --- /dev/null +++ b/tests/e2e/tests/browser-quickstart/sign-in-out.spec.ts @@ -0,0 +1,74 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * browser/quickstart โ€” sign in and sign out via the redirect flow (vanilla JS + @thunderid/browser). + * See react-quickstart/sign-in-out.spec.ts for the rest of the prerequisites. + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.BROWSER); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('browser/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({browserQuickstartPage}) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({browserQuickstartPage}) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.logout(); + await browserQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({browserQuickstartPage}) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.openTokenDebug(); + await browserQuickstartPage.verifyTokenDebugLoaded(); + + const token = await browserQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test('TC004: profile changes made via Manage Profile are reflected', async ({browserQuickstartPage}) => { + await browserQuickstartPage.goto(appUrl); + await browserQuickstartPage.verifyHomePageLoaded(); + await browserQuickstartPage.clickSignInButton(); + await browserQuickstartPage.verifyLoginPageLoaded(); + await browserQuickstartPage.login(username, password); + await browserQuickstartPage.verifyLoggedIn(); + + await browserQuickstartPage.openManageProfile(); + await browserQuickstartPage.fillProfileName('Profile', 'Updated'); + await browserQuickstartPage.saveProfile(); + + await browserQuickstartPage.verifyDisplayedName('Profile Updated'); + }); +}); diff --git a/tests/e2e/tests/express-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/express-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..4b084f2 --- /dev/null +++ b/tests/e2e/tests/express-quickstart/sign-in-out.spec.ts @@ -0,0 +1,63 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * express/quickstart โ€” sign in and sign out via the redirect flow (server-rendered + cookie + * session, no client SDK). See react-quickstart/sign-in-out.spec.ts for the rest of the + * prerequisites. + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.EXPRESS); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('express/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({expressQuickstartPage}) => { + await expressQuickstartPage.goto(appUrl); + await expressQuickstartPage.verifyHomePageLoaded(); + + await expressQuickstartPage.clickSignInButton(); + await expressQuickstartPage.verifyLoginPageLoaded(); + + await expressQuickstartPage.login(username, password); + await expressQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({expressQuickstartPage}) => { + await expressQuickstartPage.goto(appUrl); + await expressQuickstartPage.verifyHomePageLoaded(); + await expressQuickstartPage.clickSignInButton(); + await expressQuickstartPage.verifyLoginPageLoaded(); + await expressQuickstartPage.login(username, password); + await expressQuickstartPage.verifyLoggedIn(); + + await expressQuickstartPage.logout(); + await expressQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({expressQuickstartPage}) => { + await expressQuickstartPage.goto(appUrl); + await expressQuickstartPage.verifyHomePageLoaded(); + await expressQuickstartPage.clickSignInButton(); + await expressQuickstartPage.verifyLoginPageLoaded(); + await expressQuickstartPage.login(username, password); + await expressQuickstartPage.verifyLoggedIn(); + + await expressQuickstartPage.openTokenDebug(); + await expressQuickstartPage.verifyTokenDebugLoaded(); + + const token = await expressQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + // No TC004 (profile management) here: express/quickstart's user menu only has "Token debug" and + // "Sign out" (see lib/layout.mjs) โ€” no profile UI at all. +}); diff --git a/tests/e2e/tests/nextjs-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/nextjs-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..ff85233 --- /dev/null +++ b/tests/e2e/tests/nextjs-quickstart/sign-in-out.spec.ts @@ -0,0 +1,77 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * nextjs/quickstart โ€” sign in and sign out via the redirect flow. Forced into redirect mode for + * this suite (`prepare-dev:redirect`) rather than its checked-in native/embedded default โ€” see + * run-e2e.sh. See react-quickstart/sign-in-out.spec.ts for the rest of the prerequisites. + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {ProfileFieldLabels} from '../../pages/thunderid-web-sample.page'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.NEXTJS); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('nextjs/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({nextjsQuickstartPage}) => { + await nextjsQuickstartPage.goto(appUrl); + await nextjsQuickstartPage.verifyHomePageLoaded(); + + await nextjsQuickstartPage.clickSignInButton(); + await nextjsQuickstartPage.verifyLoginPageLoaded(); + + await nextjsQuickstartPage.login(username, password); + await nextjsQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({nextjsQuickstartPage}) => { + await nextjsQuickstartPage.goto(appUrl); + await nextjsQuickstartPage.verifyHomePageLoaded(); + await nextjsQuickstartPage.clickSignInButton(); + await nextjsQuickstartPage.verifyLoginPageLoaded(); + await nextjsQuickstartPage.login(username, password); + await nextjsQuickstartPage.verifyLoggedIn(); + + await nextjsQuickstartPage.logout(); + await nextjsQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({nextjsQuickstartPage}) => { + await nextjsQuickstartPage.goto(appUrl); + await nextjsQuickstartPage.verifyHomePageLoaded(); + await nextjsQuickstartPage.clickSignInButton(); + await nextjsQuickstartPage.verifyLoginPageLoaded(); + await nextjsQuickstartPage.login(username, password); + await nextjsQuickstartPage.verifyLoggedIn(); + + await nextjsQuickstartPage.openTokenDebug(); + await nextjsQuickstartPage.verifyTokenDebugLoaded(); + + const token = await nextjsQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test('TC004: profile changes made via Manage Profile are reflected', async ({nextjsQuickstartPage}) => { + await nextjsQuickstartPage.goto(appUrl); + await nextjsQuickstartPage.verifyHomePageLoaded(); + await nextjsQuickstartPage.clickSignInButton(); + await nextjsQuickstartPage.verifyLoginPageLoaded(); + await nextjsQuickstartPage.login(username, password); + await nextjsQuickstartPage.verifyLoggedIn(); + + await nextjsQuickstartPage.openManageProfile(); + await nextjsQuickstartPage.editProfileField(ProfileFieldLabels.givenName, 'Profile'); + await nextjsQuickstartPage.editProfileField(ProfileFieldLabels.familyName, 'Updated'); + + await nextjsQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.givenName, 'Profile'); + await nextjsQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.familyName, 'Updated'); + }); +}); diff --git a/tests/e2e/tests/node-quickstart/client-credentials.spec.ts b/tests/e2e/tests/node-quickstart/client-credentials.spec.ts new file mode 100644 index 0000000..92adee5 --- /dev/null +++ b/tests/e2e/tests/node-quickstart/client-credentials.spec.ts @@ -0,0 +1,44 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * node/quickstart โ€” a service-to-service CLI, not a browser app: it authenticates via the OAuth2 + * `client_credentials` grant (samples/node/quickstart/lib/AuthService.mjs) and prints the result. + * No page to drive, so this spawns the CLI directly and asserts it exits 0 having actually + * obtained a token, rather than being skipped from E2E coverage entirely. + */ + +import {ChildProcess, spawn} from 'node:child_process'; +import path from 'node:path'; +import {test, expect} from '@playwright/test'; +import {Timeouts} from '../../constants/timeouts'; + +const APP_DIR = path.resolve(import.meta.dirname, '../../../../samples/node/quickstart'); + +test.describe('node/quickstart - client_credentials', () => { + test('TC001: obtains an access token and exits cleanly', async () => { + let child: ChildProcess | undefined; + + try { + const result = await new Promise<{code: number | null; stderr: string; stdout: string}>((resolve, reject) => { + // `timeout` bounds a hung token request (e.g. a network stall) instead of leaving the + // child running indefinitely in the background โ€” Playwright's own test timeout aborts + // this promise's consumer, but never kills a spawned child process on its own. + child = spawn('node', ['index.mjs'], {cwd: APP_DIR, env: process.env, timeout: Timeouts.DEFAULT_ACTION}); + let stdout = ''; + let stderr = ''; + child.stdout!.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr!.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.on('error', reject); + child.on('close', (code) => resolve({code, stderr, stdout})); + }); + + expect(result.code, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + // lib/ui.mjs's printAuthenticated() prints this banner once client_credentials succeeds. + expect(result.stdout).toContain('AUTHENTICATED'); + } finally { + // No-op if the process already exited โ€” kill() on a dead process just returns false. + child?.kill(); + } + }); +}); diff --git a/tests/e2e/tests/nuxt-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/nuxt-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..f9e3222 --- /dev/null +++ b/tests/e2e/tests/nuxt-quickstart/sign-in-out.spec.ts @@ -0,0 +1,76 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * nuxt/quickstart โ€” sign in and sign out via the redirect flow (already the checked-in default). + * See react-quickstart/sign-in-out.spec.ts for the rest of the prerequisites. + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {ProfileFieldLabels} from '../../pages/thunderid-web-sample.page'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.NUXT); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('nuxt/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({nuxtQuickstartPage}) => { + await nuxtQuickstartPage.goto(appUrl); + await nuxtQuickstartPage.verifyHomePageLoaded(); + + await nuxtQuickstartPage.clickSignInButton(); + await nuxtQuickstartPage.verifyLoginPageLoaded(); + + await nuxtQuickstartPage.login(username, password); + await nuxtQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({nuxtQuickstartPage}) => { + await nuxtQuickstartPage.goto(appUrl); + await nuxtQuickstartPage.verifyHomePageLoaded(); + await nuxtQuickstartPage.clickSignInButton(); + await nuxtQuickstartPage.verifyLoginPageLoaded(); + await nuxtQuickstartPage.login(username, password); + await nuxtQuickstartPage.verifyLoggedIn(); + + await nuxtQuickstartPage.logout(); + await nuxtQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({nuxtQuickstartPage}) => { + await nuxtQuickstartPage.goto(appUrl); + await nuxtQuickstartPage.verifyHomePageLoaded(); + await nuxtQuickstartPage.clickSignInButton(); + await nuxtQuickstartPage.verifyLoginPageLoaded(); + await nuxtQuickstartPage.login(username, password); + await nuxtQuickstartPage.verifyLoggedIn(); + + await nuxtQuickstartPage.openTokenDebug(); + await nuxtQuickstartPage.verifyTokenDebugLoaded(); + + const token = await nuxtQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test('TC004: profile changes made via Manage Profile are reflected', async ({nuxtQuickstartPage}) => { + await nuxtQuickstartPage.goto(appUrl); + await nuxtQuickstartPage.verifyHomePageLoaded(); + await nuxtQuickstartPage.clickSignInButton(); + await nuxtQuickstartPage.verifyLoginPageLoaded(); + await nuxtQuickstartPage.login(username, password); + await nuxtQuickstartPage.verifyLoggedIn(); + + await nuxtQuickstartPage.openManageProfile(); + await nuxtQuickstartPage.editProfileField(ProfileFieldLabels.givenName, 'Profile'); + await nuxtQuickstartPage.editProfileField(ProfileFieldLabels.familyName, 'Updated'); + + await nuxtQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.givenName, 'Profile'); + await nuxtQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.familyName, 'Updated'); + }); +}); diff --git a/tests/e2e/tests/react-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/react-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..9b4a332 --- /dev/null +++ b/tests/e2e/tests/react-quickstart/sign-in-out.spec.ts @@ -0,0 +1,81 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * react/quickstart โ€” sign in and sign out via the redirect flow. + * + * Prerequisites (handled by global-setup.ts / run-e2e.sh): + * - The backend running, with react/quickstart's OAuth2 client imported + * (thunderid-config/sample-apps.yaml) + * - react/quickstart running at REACT_APP_URL + * - The shared E2E test user (TEST_USER_USERNAME / TEST_USER_PASSWORD) + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {ProfileFieldLabels} from '../../pages/thunderid-web-sample.page'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.REACT); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('react/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({reactQuickstartPage}) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({reactQuickstartPage}) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.logout(); + await reactQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({reactQuickstartPage}) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.openTokenDebug(); + await reactQuickstartPage.verifyTokenDebugLoaded(); + + const token = await reactQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test('TC004: profile changes made via Manage Profile are reflected', async ({reactQuickstartPage}) => { + await reactQuickstartPage.goto(appUrl); + await reactQuickstartPage.verifyHomePageLoaded(); + await reactQuickstartPage.clickSignInButton(); + await reactQuickstartPage.verifyLoginPageLoaded(); + await reactQuickstartPage.login(username, password); + await reactQuickstartPage.verifyLoggedIn(); + + await reactQuickstartPage.openManageProfile(); + await reactQuickstartPage.editProfileField(ProfileFieldLabels.givenName, 'Profile'); + await reactQuickstartPage.editProfileField(ProfileFieldLabels.familyName, 'Updated'); + + await reactQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.givenName, 'Profile'); + await reactQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.familyName, 'Updated'); + }); +}); diff --git a/tests/e2e/tests/vue-quickstart/sign-in-out.spec.ts b/tests/e2e/tests/vue-quickstart/sign-in-out.spec.ts new file mode 100644 index 0000000..cadbdca --- /dev/null +++ b/tests/e2e/tests/vue-quickstart/sign-in-out.spec.ts @@ -0,0 +1,76 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * vue/quickstart โ€” sign in and sign out via the redirect flow. See + * react-quickstart/sign-in-out.spec.ts for prerequisites; identical shape, different app. + */ + +import {SampleApps, sampleAppUrl} from '../../constants/sample-apps'; +import {expect, test} from '../../fixtures/sample-apps'; +import {ProfileFieldLabels} from '../../pages/thunderid-web-sample.page'; +import {decodeJwtPayload} from '../../utils/jwt'; + +const appUrl = sampleAppUrl(SampleApps.VUE); +const username = process.env.TEST_USER_USERNAME!; +const password = process.env.TEST_USER_PASSWORD!; + +test.describe('vue/quickstart - Sign in and Sign out', () => { + test('TC001: signs in with valid credentials', async ({vueQuickstartPage}) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + }); + + test('TC002: signs out after a successful sign-in', async ({vueQuickstartPage}) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.logout(); + await vueQuickstartPage.verifyLoggedOut(); + }); + + test('TC003: token debug page displays a valid access token', async ({vueQuickstartPage}) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.openTokenDebug(); + await vueQuickstartPage.verifyTokenDebugLoaded(); + + const token = await vueQuickstartPage.getDisplayedAccessToken(); + expect(token.split('.')).toHaveLength(3); + + const payload = decodeJwtPayload(token); + expect(payload.sub).toBeTruthy(); + expect(payload.exp as number).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test('TC004: profile changes made via Manage Profile are reflected', async ({vueQuickstartPage}) => { + await vueQuickstartPage.goto(appUrl); + await vueQuickstartPage.verifyHomePageLoaded(); + await vueQuickstartPage.clickSignInButton(); + await vueQuickstartPage.verifyLoginPageLoaded(); + await vueQuickstartPage.login(username, password); + await vueQuickstartPage.verifyLoggedIn(); + + await vueQuickstartPage.openManageProfile(); + await vueQuickstartPage.editProfileField(ProfileFieldLabels.givenName, 'Profile'); + await vueQuickstartPage.editProfileField(ProfileFieldLabels.familyName, 'Updated'); + + await vueQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.givenName, 'Profile'); + await vueQuickstartPage.verifyProfileFieldValue(ProfileFieldLabels.familyName, 'Updated'); + }); +}); diff --git a/tests/e2e/thunderid-config/sample-apps.yaml b/tests/e2e/thunderid-config/sample-apps.yaml new file mode 100644 index 0000000..12db309 --- /dev/null +++ b/tests/e2e/thunderid-config/sample-apps.yaml @@ -0,0 +1,343 @@ +# Copyright 2025 The ThunderID Authors +# SPDX-License-Identifier: Apache-2.0 +# +# Declarative OAuth2 application registrations for every sample quickstart app under E2E test, +# imported via `POST /import` (see utils/import-config or run-e2e.sh): the six browser-testable +# apps (authorization_code) plus node/quickstart (client_credentials, no browser UI, no redirect +# URIs), and a shared CORS allowlist covering the six browser apps' origins. Double-brace +# placeholders below are filled from the `variables` map passed alongside this file's content โ€” +# see constants/sample-apps.ts for the canonical port/clientId values these are expected to match. +# +# Note: this whole file is parsed as a Go template, comments included โ€” never write a literal +# double-brace placeholder in prose here (even in a comment), or the template engine will try to +# resolve it as real template syntax and fail the import. +# +# Modeled directly on thunderid/tests/e2e/thunderid-config-sample-apps.yaml. + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b1 +ouHandle: default +name: E2E browser/quickstart +description: OAuth2 client for the browser SDK quickstart's E2E suite +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.BROWSER_CLIENT_ID}}" + redirectUris: + {{- range .BROWSER_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .BROWSER_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: none + pkceRequired: true + publicClient: true + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b2 +ouHandle: default +name: E2E react/quickstart +description: OAuth2 client for the React SDK quickstart's E2E suite +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.REACT_CLIENT_ID}}" + redirectUris: + {{- range .REACT_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .REACT_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: none + pkceRequired: true + publicClient: true + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b3 +ouHandle: default +name: E2E vue/quickstart +description: OAuth2 client for the Vue SDK quickstart's E2E suite +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.VUE_CLIENT_ID}}" + redirectUris: + {{- range .VUE_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .VUE_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: none + pkceRequired: true + publicClient: true + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b4 +ouHandle: default +name: E2E nextjs/quickstart +description: OAuth2 client for the Next.js SDK quickstart's E2E suite (redirect flow) +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.NEXTJS_CLIENT_ID}}" + clientSecret: "{{.NEXTJS_CLIENT_SECRET}}" + redirectUris: + {{- range .NEXTJS_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .NEXTJS_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: client_secret_basic + pkceRequired: true + publicClient: false + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b5 +ouHandle: default +name: E2E nuxt/quickstart +description: OAuth2 client for the Nuxt SDK quickstart's E2E suite (redirect flow) +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.NUXT_CLIENT_ID}}" + clientSecret: "{{.NUXT_CLIENT_SECRET}}" + redirectUris: + {{- range .NUXT_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .NUXT_POST_LOGOUT_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: client_secret_basic + pkceRequired: true + publicClient: false + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b6 +ouHandle: default +name: E2E express/quickstart +description: OAuth2 client for the Express SDK quickstart's E2E suite +type: browser +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +assertion: + validityPeriod: 3600 +loginConsent: + validityPeriod: 0 +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.EXPRESS_CLIENT_ID}}" + clientSecret: "{{.EXPRESS_CLIENT_SECRET}}" + redirectUris: + {{- range .EXPRESS_REDIRECT_URIS}} + - {{.}} + {{- end}} + postLogoutRedirectUris: + {{- range .EXPRESS_POST_LOGOUT_REDIRECT_URIS}} + - {{.}} + {{- end}} + grantTypes: + - authorization_code + responseTypes: + - code + tokenEndpointAuthMethod: client_secret_basic + pkceRequired: true + publicClient: false + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: [given_name, family_name, email] + idToken: + validityPeriod: 3600 + userAttributes: [given_name, family_name, email] + responseType: JWT + userInfo: + responseType: JSON + userAttributes: [given_name, family_name, email] + +--- +resource_type: application +id: 019a0000-0000-7000-8000-0000000000b7 +ouHandle: default +name: E2E node/quickstart +description: OAuth2 client for the Node SDK quickstart's E2E suite (client_credentials, no browser UI) +type: fullstack +authFlowHandle: default-flow +isRegistrationFlowEnabled: false +isRecoveryFlowEnabled: false +isSignOutFlowEnabled: false +inboundAuthConfig: + - type: oauth2 + config: + clientId: "{{.NODE_CLIENT_ID}}" + clientSecret: "{{.NODE_CLIENT_SECRET}}" + grantTypes: + - client_credentials + tokenEndpointAuthMethod: client_secret_basic + pkceRequired: false + publicClient: false + +--- +resource_type: server_config +name: cors +value: + allowedOrigins: + {{- range .ALL_ORIGINS}} + - {{.}} + {{- end}} diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..6cee8c9 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "playwright-report", "test-results", "blob-report"] +} diff --git a/tests/e2e/utils/api-request/index.ts b/tests/e2e/utils/api-request/index.ts new file mode 100644 index 0000000..fc187ef --- /dev/null +++ b/tests/e2e/utils/api-request/index.ts @@ -0,0 +1,50 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Authenticated Backend Request Helpers + * + * The plumbing every API helper needs: the backend base URL, an admin bearer token, the + * self-signed-cert allowance, and one consistent error carrying the status and body. + * + * Exported as functions rather than a base class: the helpers are peers, not a hierarchy, and + * the only shared state (the token) lives in `utils/authentication`, memoized process-wide. + * + * Modeled on thunderid/tests/e2e/utils/api-request/index.ts. + */ + +import {getAdminToken} from '../authentication'; + +export const serverUrl = process.env.SERVER_URL ?? 'https://localhost:8090'; + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +let dispatcherPromise: Promise | undefined; + +async function insecureDispatcher(): Promise { + dispatcherPromise ??= import('undici').then(({Agent}) => new Agent({connect: {rejectUnauthorized: false}})); + return dispatcherPromise; +} + +/** Authenticated call against the backend. Returns the response as-is, non-2xx included. */ +export async function send(method: Method, path: string, data?: unknown): Promise { + const token = await getAdminToken(); + return fetch(`${serverUrl}${path}`, { + body: data === undefined ? undefined : JSON.stringify(data), + dispatcher: await insecureDispatcher(), + headers: { + Authorization: `Bearer ${token}`, + ...(data === undefined ? {} : {'Content-Type': 'application/json'}), + }, + method, + } as RequestInit); +} + +/** Same as `send`, but a non-2xx becomes an error carrying the status and response body. */ +export async function sendOk(method: Method, path: string, data?: unknown): Promise { + const response = await send(method, path, data); + if (!response.ok) { + throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + } + return response; +} diff --git a/tests/e2e/utils/authentication/index.ts b/tests/e2e/utils/authentication/index.ts new file mode 100644 index 0000000..731cdc7 --- /dev/null +++ b/tests/e2e/utils/authentication/index.ts @@ -0,0 +1,140 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Admin token acquisition for the E2E suite's own API calls (declarative config import, test user + * creation/cleanup) โ€” not the sample apps' own sign-in flow under test, which each Page Object + * drives through the real UI instead. + * + * In CI, `run-e2e.sh`/the workflow mints a token via the CONSOLE app (OAuth2 authorization_code + + * PKCE, same as thunderid's own `.github/actions/obtain-admin-token`) and passes it through + * `ADMIN_TOKEN`. Locally, this mints its own the same way, following the exact sequence + * thunderid/tests/e2e/run-e2e.sh's `mint_admin_token` uses: authorize -> flow/execute (SSO + * check) -> flow/execute (credentials) -> oauth2/auth/callback -> oauth2/token. + */ + +import {createHash, randomBytes} from 'node:crypto'; + +const CONSOLE_CLIENT_ID = 'CONSOLE'; + +function base64url(input: Buffer): string { + return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +let tokenPromise: Promise | undefined; + +/** One admin token per process, memoized โ€” every helper in the suite shares it. */ +export function getAdminToken(): Promise { + tokenPromise ??= mintAdminToken().catch((error: unknown) => { + tokenPromise = undefined; + throw error; + }); + return tokenPromise; +} + +async function mintAdminToken(): Promise { + if (process.env.ADMIN_TOKEN) { + return process.env.ADMIN_TOKEN; + } + + const serverUrl = process.env.SERVER_URL ?? 'https://localhost:8090'; + const adminUsername = process.env.ADMIN_USERNAME ?? 'admin'; + const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin'; + const redirectUri = `${serverUrl}/console`; + + const codeVerifier = base64url(randomBytes(32)).slice(0, 43); + const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest()); + + // Self-signed local/CI certs โ€” same allowance run-e2e.sh's curl calls make with `-k`. + const dispatcher = await getInsecureDispatcher(); + + const authorizeUrl = new URL(`${serverUrl}/oauth2/authorize`); + authorizeUrl.searchParams.set('client_id', CONSOLE_CLIENT_ID); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', 'system'); + authorizeUrl.searchParams.set('resource', `${serverUrl}/mcp`); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + + const authorizeRes = await fetch(authorizeUrl, {dispatcher, redirect: 'manual'} as RequestInit); + const location = authorizeRes.headers.get('location'); + if (!location) { + throw new Error(`No Location header from ${authorizeUrl.pathname}: HTTP ${authorizeRes.status}`); + } + const locationUrl = new URL(location, serverUrl); + const authId = locationUrl.searchParams.get('authId'); + const executionId = locationUrl.searchParams.get('executionId'); + if (!authId || !executionId) { + throw new Error(`Failed to parse authId/executionId from authorize redirect: ${location}`); + } + + // First execute advances past the SSO check (a fresh, cookie-less login) to the credentials + // prompt and mints a challenge token. + const promptRes = await fetch(`${serverUrl}/flow/execute`, { + body: JSON.stringify({executionId}), + dispatcher, + headers: {'Content-Type': 'application/json'}, + method: 'POST', + } as RequestInit); + const promptData = (await promptRes.json()) as {challengeToken?: string}; + if (!promptData.challengeToken) { + throw new Error(`Flow execution did not return a challenge token: ${JSON.stringify(promptData)}`); + } + + const credsRes = await fetch(`${serverUrl}/flow/execute`, { + body: JSON.stringify({ + action: 'action_001', + challengeToken: promptData.challengeToken, + executionId, + inputs: {password: adminPassword, username: adminUsername}, + }), + dispatcher, + headers: {'Content-Type': 'application/json'}, + method: 'POST', + } as RequestInit); + const credsData = (await credsRes.json()) as {assertion?: string}; + if (!credsData.assertion) { + throw new Error(`Admin authentication failed: ${JSON.stringify(credsData)}`); + } + + const callbackRes = await fetch(`${serverUrl}/oauth2/auth/callback`, { + body: JSON.stringify({assertion: credsData.assertion, authId}), + dispatcher, + headers: {'Content-Type': 'application/json'}, + method: 'POST', + } as RequestInit); + const callbackData = (await callbackRes.json()) as {redirect_uri?: string}; + const authCode = callbackData.redirect_uri ? new URL(callbackData.redirect_uri).searchParams.get('code') : null; + if (!authCode) { + throw new Error(`OAuth2 callback did not return an authorization code: ${JSON.stringify(callbackData)}`); + } + + const tokenRes = await fetch(`${serverUrl}/oauth2/token`, { + body: new URLSearchParams({ + client_id: CONSOLE_CLIENT_ID, + code: authCode, + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + resource: `${serverUrl}/mcp`, + }), + dispatcher, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + method: 'POST', + } as RequestInit); + const tokenData = (await tokenRes.json()) as {access_token?: string}; + if (!tokenData.access_token) { + throw new Error(`Failed to obtain admin access token: ${JSON.stringify(tokenData)}`); + } + + return tokenData.access_token; +} + +// Node's global fetch (undici) needs an explicit dispatcher to skip TLS verification for the +// backend's self-signed local/CI cert โ€” equivalent to curl's `-k` in run-e2e.sh. Loaded lazily so +// `undici` is only required when actually minting a token (not when ADMIN_TOKEN is preset). +async function getInsecureDispatcher(): Promise { + const {Agent} = await import('undici'); + return new Agent({connect: {rejectUnauthorized: false}}); +} diff --git a/tests/e2e/utils/jwt/index.ts b/tests/e2e/utils/jwt/index.ts new file mode 100644 index 0000000..c2c939f --- /dev/null +++ b/tests/e2e/utils/jwt/index.ts @@ -0,0 +1,17 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Decodes an access token's JWT payload for assertion purposes only (no signature + * verification โ€” the token debug pages under test do the exact same client-side decode, see + * e.g. samples/react/quickstart/src/pages/TokenDebugPage.jsx's decodeJwtPart()). + */ +export function decodeJwtPayload(token: string): Record { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error(`Expected a JWT with 3 dot-separated parts, got ${parts.length}`); + } + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')) as Record; +} diff --git a/tests/e2e/utils/test-data/index.ts b/tests/e2e/utils/test-data/index.ts new file mode 100644 index 0000000..1d512d8 --- /dev/null +++ b/tests/e2e/utils/test-data/index.ts @@ -0,0 +1,36 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Test data generators, so parallel runs never collide on username/email. + * + * Modeled on thunderid/tests/e2e/utils/test-data/index.ts. + */ + +import {randomBytes} from 'node:crypto'; + +export interface UserData { + email: string; + family_name: string; + given_name: string; + password: string; + username: string; +} + +function generateUniqueId(prefix: string): string { + return `${prefix}_${Date.now()}_${randomBytes(4).toString('hex')}`; +} + +export const TestDataFactory = { + createUser(overrides?: Partial): UserData { + const uniqueId = generateUniqueId('e2e'); + return { + email: `${uniqueId}@example.com`, + family_name: `Last_${uniqueId}`, + given_name: `First_${uniqueId}`, + password: process.env.TEST_USER_PASSWORD ?? 'E2ePassword@123', + username: `${uniqueId}`, + ...overrides, + }; + }, +}; diff --git a/tests/e2e/utils/user-types-api/index.ts b/tests/e2e/utils/user-types-api/index.ts new file mode 100644 index 0000000..855f4f7 --- /dev/null +++ b/tests/e2e/utils/user-types-api/index.ts @@ -0,0 +1,41 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * User Types API Helper + * + * Thin wrapper over the backend `/user-types` endpoint. Owns the "resolve a user type by name" + * lookup that the users API needs to create the shared E2E test user. + * + * Modeled on thunderid/tests/e2e/utils/user-types-api/index.ts. + */ + +import {sendOk} from '../api-request'; + +export interface ApiUserType { + id: string; + name: string; + ouId: string; +} + +/** Every user type in the system. The list endpoint has no name filter, so this pages until done. */ +export async function listUserTypes(): Promise { + const pageSize = 100; + const all: ApiUserType[] = []; + + for (let offset = 0; ; ) { + const response = await sendOk('GET', `/user-types?limit=${pageSize}&offset=${offset}`); + const body = (await response.json()) as {types?: ApiUserType[]; totalResults?: number}; + const page = body.types ?? []; + all.push(...page); + offset += page.length; + if (page.length < pageSize || offset >= (body.totalResults ?? offset)) { + return all; + } + } +} + +export async function findUserTypeByName(name: string): Promise { + const types = await listUserTypes(); + return types.find((type) => type.name === name); +} diff --git a/tests/e2e/utils/users-api/index.ts b/tests/e2e/utils/users-api/index.ts new file mode 100644 index 0000000..653cc35 --- /dev/null +++ b/tests/e2e/utils/users-api/index.ts @@ -0,0 +1,40 @@ +// Copyright 2025 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Users API Helper + * + * Thin wrapper over the backend `/users` endpoint for E2E setup/teardown โ€” creating the shared + * test user before the suite runs and deleting it afterward. + * + * Modeled on thunderid/tests/e2e/utils/users-api/index.ts. + */ + +import {send, sendOk} from '../api-request'; +import {findUserTypeByName} from '../user-types-api'; + +export interface ApiUser { + id: string; + ouId: string; + type: string; +} + +/** Create a user directly via the API, bypassing any UI. */ +export async function createUser(attributes: Record, type = 'Person'): Promise { + const userType = await findUserTypeByName(type); + if (!userType) { + throw new Error(`GET /user-types returned no "${type}" user type`); + } + const response = await sendOk('POST', '/users', { + attributes, + ouId: userType.ouId, + type: userType.name, + }); + return (await response.json()) as ApiUser; +} + +/** Delete a user by id. Returns false (rather than throwing) if the delete failed. */ +export async function deleteUser(userId: string): Promise { + const response = await send('DELETE', `/users/${userId}`); + return response.ok; +}