From 7f7bbc6b26670eb2c095b38d4c735d04e12e4c97 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 24 Aug 2026 12:29:33 -0600 Subject: [PATCH 1/3] test: add OIDC social-login setPassword step-up fallback E2E (#90) Follow-up to #75. Adds the browser E2E that proves an OIDC (Keycloak) account with no passkey has POST /user/setPassword governed by allowInitialPasswordSetWithoutStepUp (403 when false, succeeds when true) rather than a permanent 401 step-up, and that a freshly logged-in OIDC user can enroll a first passkey (FACTOR_AUTHORIZATION_CODE). - New chromium-step-up-oidc Playwright project (grep @step-up-oidc), excluded from the default/step-up projects. - globalSetup/globalTeardown start a dev-mode Keycloak container (realm mounted, no external DB) when KEYCLOAK_E2E is set; reuse an already running provider, and fail fast rather than destroy a live one. - step-up-oidc.spec.ts: real Keycloak redirect login, both fallback branches (selected per app boot via STEP_UP_OIDC_ALLOW_INITIAL and a SPRING_APPLICATION_JSON override), and first-passkey enrollment. - New playwright-tests-oidc CI job mirroring playwright-tests, with a Keycloak provider (via globalSetup) and a Mailpit service so the passkey-registration notification stays local instead of hitting SES. - Docs: TESTING.md and AUTHENTICATION.md describe the new project, run commands, and the local-DB reset caveat. Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt --- .github/workflows/tests.yml | 109 ++++++++++++ docs/AUTHENTICATION.md | 7 + docs/TESTING.md | 50 +++++- playwright/global-setup.ts | 13 ++ playwright/global-teardown.ts | 12 ++ playwright/playwright.config.ts | 38 +++- playwright/src/utils/keycloak.ts | 155 ++++++++++++++++ playwright/tests/step-up/step-up-oidc.spec.ts | 167 ++++++++++++++++++ playwright/tsconfig.json | 2 +- 9 files changed, 540 insertions(+), 13 deletions(-) create mode 100644 playwright/global-setup.ts create mode 100644 playwright/global-teardown.ts create mode 100644 playwright/src/utils/keycloak.ts create mode 100644 playwright/tests/step-up/step-up-oidc.spec.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e748c30..d75fb0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -121,3 +121,112 @@ jobs: with: name: playwright-report path: playwright/reports/ + + playwright-tests-oidc: + name: Playwright OIDC Step-Up Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + mariadb: + image: mariadb:12.2 + env: + MARIADB_DATABASE: springuser + MARIADB_USER: springuser + MARIADB_PASSWORD: springuser + MARIADB_ROOT_PASSWORD: rootpassword + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + mailpit: + # The first-passkey-enrollment test registers a passkey, and user.webauthn.notifyOnRegistration + # is on by default, so the app sends a notification email. Without a relay it would attempt a real + # outbound SMTP connection to the base config's SES host on every run; catch it in Mailpit instead + # (the mail env below points spring.mail here). The test does not assert the mail; this just keeps + # the send local. + image: axllent/mailpit:v1.30.7 + env: + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd="/mailpit readyz" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + # The MariaDB service container replaces Spring Boot's Docker Compose integration. + SPRING_DOCKER_COMPOSE_ENABLED: "false" + # Point mail at the Mailpit service container so passkey-registration notifications stay local + # (mirrors docker-compose-keycloak.yml's SPRING_MAIL_* for the same plain-SMTP, no-auth setup). + SPRING_MAIL_HOST: localhost + SPRING_MAIL_PORT: "1025" + SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false" + SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false" + SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false" + # Tell Playwright's globalSetup to start a Keycloak provider (via docker) for this run. + KEYCLOAK_E2E: "1" + # OIDC client + provider config for the app under test. The app runs on the runner host (started by + # Playwright's webServer), not inside a compose network, so every provider URI points at the host's + # published Keycloak port 8180 rather than the compose-network keycloak:8080. Client id/secret match + # keycloak/realm/realm-export.json (dev-only credentials committed to the repo). + DS_SPRING_USER_KEYCLOAK_CLIENT_ID: ds-spring-user-framework-demo + DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET: FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ + DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI: http://localhost:8180/realms/demo/protocol/openid-connect/auth + DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI: http://localhost:8180/realms/demo/protocol/openid-connect/token + DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI: http://localhost:8180/realms/demo/protocol/openid-connect/userinfo + DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI: http://localhost:8180/realms/demo/protocol/openid-connect/certs + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build application + run: ./gradlew assemble + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: playwright/package-lock.json + + - name: Install Playwright dependencies + working-directory: playwright + run: | + npm ci + npx playwright install --with-deps chromium + + # Two boots: the allowInitialPasswordSetWithoutStepUp flag is boot-time config, so each branch of + # the OIDC fallback needs its own app start. globalSetup brings Keycloak up for each run. + - name: Run E2E (OIDC step-up, initial password allowed) + working-directory: playwright + env: + APP_PROFILES: docker-keycloak,playwright-test,step-up + STEP_UP_OIDC_ALLOW_INITIAL: "true" + run: npx playwright test --project=chromium-step-up-oidc + + - name: Run E2E (OIDC step-up, initial password denied) + working-directory: playwright + env: + APP_PROFILES: docker-keycloak,playwright-test,step-up + STEP_UP_OIDC_ALLOW_INITIAL: "false" + # Override playwright-test's flag (true) so the denial branch is exercised. + SPRING_APPLICATION_JSON: '{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}' + run: npx playwright test --project=chromium-step-up-oidc + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-oidc + path: playwright/reports/ diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 6449c5b..08e8d6f 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -125,6 +125,13 @@ browser (ceremony then retry succeeds) and the negative path (absent `WEBAUTHN` runs no ceremony). Run it with `APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up`. +The social-login fallback is covered by the `chromium-step-up-oidc` project +([`step-up-oidc.spec.ts`](../playwright/tests/step-up/step-up-oidc.spec.ts)): after a real Keycloak OIDC +login, `setPassword` on the passkey-less account is governed by `allowInitialPasswordSetWithoutStepUp` +(`403` when false, success when true) rather than a permanent `401`, and a freshly logged-in OIDC user can +enroll a first passkey. It needs a Keycloak provider and runs the app on +`docker-keycloak,playwright-test,step-up`; see [TESTING.md](TESTING.md) for the run command. + ## MFA The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), `false` in the base config diff --git a/docs/TESTING.md b/docs/TESTING.md index b4d7f2c..7afbebd 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -100,9 +100,9 @@ disables verification/reset emails (tests fetch tokens via the Test API instead) "set initial password" flow works without a `StepUpService` bean. The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs -tagged `@mfa-enabled` and `@step-up-enabled` (`grepInvert`); separate Chromium-only projects run -those, each against a server started with the matching add-on profile. Both use the CDP virtual -authenticator, so they are Chromium-only. +tagged `@mfa-enabled`, `@step-up-enabled`, and `@step-up-oidc` (`grepInvert`); separate Chromium-only +projects run those, each against a server started with the matching add-on profile. They use the CDP +virtual authenticator (and, for OIDC, a Keycloak provider), so they are Chromium-only. ```bash # MFA flow (@mfa-enabled) @@ -123,8 +123,40 @@ The step-up run adds `step-up-e2e` (`application-step-up-e2e.yml`), a test-only `bootRun` starts Mailpit automatically alongside MariaDB. The realistic demo values stay in `application-step-up.yml` (`ttlSeconds: 120`). The step-up specs run serially (`test.describe.configure({ mode: 'serial' })`) because concurrent account registration deadlocks in -MariaDB (framework issue devondragon/SpringUserFramework#368). One acceptance case is not covered here: -social-login (OIDC) `setPassword` fallback, which needs the Keycloak stack (tracked separately). +MariaDB (framework issue devondragon/SpringUserFramework#368). + +The social-login (OIDC) `setPassword` fallback is covered separately by the `chromium-step-up-oidc` +project ([`step-up-oidc.spec.ts`](../playwright/tests/step-up/step-up-oidc.spec.ts)), because it needs a +real OpenID provider. `globalSetup` starts a dev-mode Keycloak (`quay.io/keycloak/keycloak:25.0.6 +start-dev --import-realm`, the `keycloak/realm` export mounted, no external database) whenever +`KEYCLOAK_E2E` is set, and `globalTeardown` removes it; an already-running Keycloak is reused and left +alone. The app runs on the host on `docker-keycloak,playwright-test,step-up`, with the +`DS_SPRING_USER_KEYCLOAK_*` provider URIs pointed at the host's published Keycloak port (`localhost:8180`) +rather than the compose-network `keycloak:8080`. The `allowInitialPasswordSetWithoutStepUp` flag is +boot-time config, so each branch is its own app boot, selected by `STEP_UP_OIDC_ALLOW_INITIAL`: + +```bash +# setPassword succeeds (flag true, from playwright-test) +KEYCLOAK_E2E=1 STEP_UP_OIDC_ALLOW_INITIAL=true \ + DS_SPRING_USER_KEYCLOAK_CLIENT_ID=ds-spring-user-framework-demo \ + DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET=FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ \ + DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI=http://localhost:8180/realms/demo/protocol/openid-connect/auth \ + DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI=http://localhost:8180/realms/demo/protocol/openid-connect/token \ + DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI=http://localhost:8180/realms/demo/protocol/openid-connect/userinfo \ + DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI=http://localhost:8180/realms/demo/protocol/openid-connect/certs \ + APP_PROFILES=docker-keycloak,playwright-test,step-up \ + npx playwright test --project=chromium-step-up-oidc + +# setPassword denied with 403, not a permanent 401 (flag false) +# ...same env, but: STEP_UP_OIDC_ALLOW_INITIAL=false and +# SPRING_APPLICATION_JSON='{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}' +``` + +The spec logs in as the realm's single seeded user (`demo@example.com`) and resets the local +KEYCLOAK-provisioned account before each test so re-runs stay deterministic. Run locally, `bootRun` uses +your normal development database (`compose.dev.yaml`, port 3306), so the reset deletes any local account +at that address: do not keep a real account you care about under `demo@example.com` in your dev database. +In CI the database is a throwaway service container, so nothing persists. **Test API**: [`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) @@ -138,6 +170,10 @@ disables CSRF for `/api/test/**` and restricts it to requests from `127.0.0.1`, [`.github/workflows/tests.yml`](../.github/workflows/tests.yml) runs on pull requests and pushes to `main`: **`unit-tests`** runs `./gradlew test` on Java 21. **`playwright-tests`** builds the -app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E twice: once +app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E three times: once with `APP_PROFILES=playwright-test` against `chromium` (MFA off), once with -`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on). +`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on), and once with +`APP_PROFILES=local,playwright-test,step-up,step-up-e2e` against `chromium-step-up`. **`playwright-tests-oidc`** +covers the OIDC `setPassword` fallback: it starts the same MariaDB service, and `globalSetup` brings up a +dev-mode Keycloak on the runner, then runs `chromium-step-up-oidc` twice (once per +`allowInitialPasswordSetWithoutStepUp` branch). diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 0000000..863aa6f --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,13 @@ +import { startKeycloak } from './src/utils/keycloak'; + +/** + * Global setup, run once before the suite. Only the chromium-step-up-oidc project needs an external + * OpenID provider, so this brings Keycloak up only when KEYCLOAK_E2E is set (the OIDC run command and + * CI job set it). Every other project skips this and relies solely on the `webServer` block. + */ +async function globalSetup(): Promise { + if (!process.env.KEYCLOAK_E2E) return; + await startKeycloak(); +} + +export default globalSetup; diff --git a/playwright/global-teardown.ts b/playwright/global-teardown.ts new file mode 100644 index 0000000..f2e5904 --- /dev/null +++ b/playwright/global-teardown.ts @@ -0,0 +1,12 @@ +import { stopKeycloak } from './src/utils/keycloak'; + +/** + * Global teardown, run once after the suite. Removes the Keycloak container, but only the one this run + * started (stopKeycloak no-ops when the provider was reused rather than started here). + */ +async function globalTeardown(): Promise { + if (!process.env.KEYCLOAK_E2E) return; + await stopKeycloak(); +} + +export default globalTeardown; diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index b70b6bd..141e616 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -20,6 +20,11 @@ export default defineConfig({ /* Unique output directories for this project */ outputDir: path.join(__dirname, 'test-results', PROJECT_ID), + /* Bring up a Keycloak OpenID provider for the chromium-step-up-oidc project. Both hooks no-op unless + * KEYCLOAK_E2E is set, so every other project runs without Docker. See src/utils/keycloak.ts. */ + globalSetup: path.join(__dirname, 'global-setup.ts'), + globalTeardown: path.join(__dirname, 'global-teardown.ts'), + /* Run tests in files in parallel */ fullyParallel: true, @@ -93,32 +98,32 @@ export default defineConfig({ projects: [ { name: 'chromium', - grepInvert: /@mfa-enabled|@step-up-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/, use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', - grepInvert: /@mfa-enabled|@step-up-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/, use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', - grepInvert: /@mfa-enabled|@step-up-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/, use: { ...devices['Desktop Safari'] }, }, /* Test against mobile viewports */ { name: 'Mobile Chrome', - grepInvert: /@mfa-enabled|@step-up-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/, use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', - grepInvert: /@mfa-enabled|@step-up-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled|@step-up-oidc/, use: { ...devices['iPhone 12'] }, }, @@ -138,6 +143,29 @@ export default defineConfig({ grep: /@step-up-enabled/, use: { ...devices['Desktop Chrome'] }, }, + + /* Step-up OIDC fallback (issue #90): Chromium only, needs both a Keycloak OpenID provider and the + * app on the docker-keycloak,playwright-test,step-up profiles. globalSetup starts Keycloak when + * KEYCLOAK_E2E is set. The two allowInitialPasswordSetWithoutStepUp branches are separate app boots, + * selected by STEP_UP_OIDC_ALLOW_INITIAL (and the matching SPRING_APPLICATION_JSON override): + * # setPassword succeeds (flag true, from playwright-test): + * KEYCLOAK_E2E=1 STEP_UP_OIDC_ALLOW_INITIAL=true \ + * DS_SPRING_USER_KEYCLOAK_CLIENT_ID=ds-spring-user-framework-demo \ + * DS_SPRING_USER_KEYCLOAK_CLIENT_SECRET=FTp1j7sGvc4g3MFdghEX4n7RPhbu86PQ \ + * DS_SPRING_USER_KEYCLOAK_PROVIDER_AUTHORIZATION_URI=http://localhost:8180/realms/demo/protocol/openid-connect/auth \ + * DS_SPRING_USER_KEYCLOAK_PROVIDER_TOKEN_URI=http://localhost:8180/realms/demo/protocol/openid-connect/token \ + * DS_SPRING_USER_KEYCLOAK_PROVIDER_USER_INFO_URI=http://localhost:8180/realms/demo/protocol/openid-connect/userinfo \ + * DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI=http://localhost:8180/realms/demo/protocol/openid-connect/certs \ + * APP_PROFILES=docker-keycloak,playwright-test,step-up \ + * npx playwright test --project=chromium-step-up-oidc + * # setPassword denied with 403, not a permanent 401 (flag false): + * ... STEP_UP_OIDC_ALLOW_INITIAL=false \ + * SPRING_APPLICATION_JSON='{"user":{"security":{"allowInitialPasswordSetWithoutStepUp":false}}}' ... */ + { + name: 'chromium-step-up-oidc', + grep: /@step-up-oidc/, + use: { ...devices['Desktop Chrome'] }, + }, ], /* Run your local dev server before starting the tests */ diff --git a/playwright/src/utils/keycloak.ts b/playwright/src/utils/keycloak.ts new file mode 100644 index 0000000..1e1a1b3 --- /dev/null +++ b/playwright/src/utils/keycloak.ts @@ -0,0 +1,155 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +/** + * Standalone Keycloak lifecycle for the OIDC step-up E2E (chromium-step-up-oidc). + * + * The rest of the Playwright suite runs the app via the `webServer` block and needs nothing external. + * The OIDC project additionally needs an OpenID provider, so this module brings one up in a dev-mode + * container and imports the same realm the docker-compose-keycloak stack uses. It is deliberately + * decoupled from that compose file: dev mode (`start-dev`) needs no external database, so the only + * dependency is Docker, which both developer machines and the CI runner already have. + * + * Only global-setup.ts / global-teardown.ts call these, and only when KEYCLOAK_E2E is set, so the other + * Playwright projects never touch Docker. + */ + +/** Container name for the E2E Keycloak. Fixed so a stale one can be reclaimed idempotently. */ +const CONTAINER = 'suf-e2e-keycloak'; + +/** Keycloak image, kept in step with docker-compose-keycloak.yml so the realm imports identically. */ +const IMAGE = 'quay.io/keycloak/keycloak:25.0.6'; + +/** Host port the browser and the host-run app both reach Keycloak on (published from container 8080). */ +const HOST_PORT = 8180; + +/** Realm OIDC metadata; a 200 here proves both that Keycloak is up and that the demo realm imported. */ +const METADATA_URL = `http://localhost:${HOST_PORT}/realms/demo/.well-known/openid-configuration`; + +/** Marker recording that this process started the container, so teardown only stops what it owns. */ +const OWNED_MARKER = path.join(__dirname, '..', '..', 'test-results', '.keycloak-e2e-owned'); + +/** Absolute path to the realm export mounted into the container's import directory. */ +function realmDir(): string { + // __dirname is /playwright/src/utils; the realm lives at /keycloak/realm. + return path.resolve(__dirname, '..', '..', '..', 'keycloak', 'realm'); +} + +/** Whether Keycloak's realm metadata is already answering (a developer's own instance, or a prior run). */ +async function metadataReady(): Promise { + return new Promise((resolve) => { + const req = http.get(METADATA_URL, (res) => { + res.resume(); + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** Poll the metadata endpoint until it answers 200 or the timeout elapses. */ +async function waitForMetadata(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await metadataReady()) return; + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(`Keycloak did not become ready at ${METADATA_URL} within ${timeoutMs}ms`); +} + +/** Fail early with an actionable message if Docker is not usable, since KEYCLOAK_E2E requires it. */ +function assertDockerAvailable(): void { + try { + execFileSync('docker', ['info'], { stdio: 'ignore' }); + } catch { + throw new Error( + 'KEYCLOAK_E2E is set but Docker is not available. The chromium-step-up-oidc project needs Docker ' + + 'to run Keycloak. Start Docker, or unset KEYCLOAK_E2E to skip the OIDC project.' + ); + } +} + +/** + * Ensure a Keycloak instance with the demo realm is answering on HOST_PORT. + * + * If one is already up (a developer left `docker-compose-keycloak.yml` running, or started their own), + * it is reused and left running by teardown. Otherwise a dev-mode container is started, the realm is + * imported, and an ownership marker is written so teardown removes exactly what this run created. + */ +export async function startKeycloak(): Promise { + if (await metadataReady()) { + // Reuse an already-running provider; do not claim ownership, so teardown leaves it alone. + return; + } + + assertDockerAvailable(); + + // A container by this name that is still running (but not yet answering metadata) means another + // OIDC run started one and is mid-boot. Only one can own host port HOST_PORT, so fail fast rather + // than tearing down a live container out from under a concurrent run. + const running = execFileSync('docker', ['ps', '-q', '-f', `name=^${CONTAINER}$`], { + encoding: 'utf8', + }).trim(); + if (running) { + throw new Error( + `A Keycloak container named ${CONTAINER} is already running but not yet answering at ${METADATA_URL}. ` + + 'Another OIDC E2E run may be starting it (concurrent runs are not supported — they share host ' + + `port ${HOST_PORT}). If it is stale, remove it with: docker rm -f ${CONTAINER}` + ); + } + // Reclaim a stale, non-running container from an interrupted run before starting a fresh one. + try { + execFileSync('docker', ['rm', '-f', CONTAINER], { stdio: 'ignore' }); + } catch { + // No such container: nothing to reclaim. + } + + try { + // `docker run -d` blocks on the image pull before returning, so allow generous headroom, and + // capture stderr so a real failure (port conflict, bad mount, registry throttling) is diagnosable + // rather than surfacing as an opaque "Command failed". + execFileSync( + 'docker', + [ + 'run', + '-d', + '--name', + CONTAINER, + '-p', + `${HOST_PORT}:8080`, + '-v', + `${realmDir()}:/opt/keycloak/data/import`, + IMAGE, + 'start-dev', + '--import-realm', + ], + { stdio: ['ignore', 'ignore', 'pipe'], timeout: 180_000 } + ); + } catch (err: any) { + const stderr = err?.stderr ? `: ${String(err.stderr).trim()}` : ''; + throw new Error(`Failed to start the Keycloak container ${CONTAINER}${stderr}`); + } + + fs.mkdirSync(path.dirname(OWNED_MARKER), { recursive: true }); + fs.writeFileSync(OWNED_MARKER, CONTAINER); + + // Cold start does a Liquibase-free dev boot plus the realm import; allow generous headroom for CI. + await waitForMetadata(120_000); +} + +/** Stop and remove the Keycloak container, but only if this run started it. */ +export async function stopKeycloak(): Promise { + if (!fs.existsSync(OWNED_MARKER)) return; + try { + execFileSync('docker', ['rm', '-f', CONTAINER], { stdio: 'ignore' }); + } catch { + // Already gone: nothing to do. + } finally { + fs.rmSync(OWNED_MARKER, { force: true }); + } +} diff --git a/playwright/tests/step-up/step-up-oidc.spec.ts b/playwright/tests/step-up/step-up-oidc.spec.ts new file mode 100644 index 0000000..cd367e2 --- /dev/null +++ b/playwright/tests/step-up/step-up-oidc.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from '../../src/fixtures'; +import type { CDPSession, Page } from '@playwright/test'; + +/** + * Step-up (SUF-02) fallback for OIDC social-login accounts. Follow-up to #75, issue #90. + * + * An OIDC account has no passkey, so it can never satisfy a WEBAUTHN step-up. The framework must + * therefore govern its initial `setPassword` by `allowInitialPasswordSetWithoutStepUp` (403 when the + * flag is false, success when true) rather than returning the permanent `401 code 6` a passkey-only + * account gets. This spec proves that end-to-end against a real Keycloak redirect login. + * + * Requires a Keycloak provider (globalSetup starts one when KEYCLOAK_E2E is set) and the app on: + * docker-keycloak,playwright-test,step-up + * with the DS_SPRING_USER_KEYCLOAK_* provider URIs pointed at the host (localhost:8180), because the + * app runs on the host here rather than inside the compose network. See playwright.config.ts for the + * full run command. + * + * The two flag branches are separate app boots (the flag is boot-time config), selected by + * STEP_UP_OIDC_ALLOW_INITIAL and the matching SPRING_APPLICATION_JSON override. Tagged @step-up-oidc so + * only the chromium-step-up-oidc project runs it; every other project's server has step-up/OIDC off. + */ + +/** The realm's pre-seeded user (keycloak/realm/realm-export.json). Dev-only credentials. */ +const KEYCLOAK_USER = { username: 'demo', password: 'demo', email: 'demo@example.com' } as const; + +/** Which allowInitialPasswordSetWithoutStepUp branch the app under test is booted with. */ +const allowInitial = process.env.STEP_UP_OIDC_ALLOW_INITIAL === 'true'; + +/** + * Drive the full OIDC redirect login: app login page -> Keycloak form -> back to the app authenticated. + * Uses a fresh browser context (Playwright's per-test default), so there is no Keycloak SSO cookie and + * the login form is always shown. + */ +async function loginWithKeycloak(page: Page): Promise { + await page.goto('/user/login.html'); + await page.locator('a[href$="/oauth2/authorization/keycloak"]').click(); + + // Now on the Keycloak-hosted login form (published on host port 8180). + await page.waitForURL((url) => url.port === '8180', { timeout: 30000 }); + await page.locator('#username').fill(KEYCLOAK_USER.username); + await page.locator('#password').fill(KEYCLOAK_USER.password); + await page.locator('#kc-login').click(); + + // Back on the app, authenticated, off the login page. + await page.waitForURL((url) => url.host === 'localhost:8080' && !url.pathname.includes('/login'), { + timeout: 30000, + }); +} + +/** POST /user/setPassword from an authenticated app page, using that page's CSRF meta tokens. */ +async function setPassword(page: Page, newPassword: string): Promise<{ status: number; body: any }> { + // Load an authenticated page so its CSRF meta reflects the logged-in session. + await page.goto('/user/update-user.html'); + await page.waitForLoadState('domcontentloaded'); + return page.evaluate(async (pw) => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/setPassword', { + method: 'POST', + headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, + body: JSON.stringify({ newPassword: pw, confirmPassword: pw }), + }); + let body: any = null; + try { + body = await response.json(); + } catch { + body = null; + } + return { status: response.status, body }; + }, newPassword); +} + +/** + * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get(), so a passkey can be + * enrolled without a human touch. Mirrors the helper in step-up-flow.spec.ts. + */ +async function setupVirtualAuthenticator(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable'); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return cdp; +} + +/** Read the current credential id list via the management API. */ +async function getCredentialIds(page: Page): Promise { + return page.evaluate(async () => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/webauthn/credentials', { headers: { [csrfHeader]: csrfToken } }); + const creds = await response.json(); + return creds.map((c: { id: string }) => c.id); + }); +} + +test.describe('Step-Up OIDC fallback @step-up-oidc', () => { + // Serial: each test logs in as the same Keycloak user, which maps to one local account. Running them + // one at a time keeps the per-test reset (below) from racing a concurrent login re-provisioning it. + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ testApiClient }) => { + // Delete the local KEYCLOAK-provisioned account so the next OIDC login re-creates it fresh with no + // password. This keeps the true/false branches and re-runs deterministic (a prior "success" run + // would otherwise leave a password behind, changing setPassword's behavior). + await testApiClient.cleanupUser(KEYCLOAK_USER.email); + }); + + test('OIDC setPassword succeeds when allowInitialPasswordSetWithoutStepUp is true', async ({ page }) => { + test.skip(!allowInitial, 'App booted with the flag false; the success branch does not apply.'); + + await loginWithKeycloak(page); + + const result = await setPassword(page, 'Test@Pass123!'); + // The fallback allows the initial set: 200, not a step-up 401 and not a 403 denial. + expect(result.status, `setPassword body: ${JSON.stringify(result.body)}`).toBe(200); + + // The account now has a password (auth-methods wraps its fields in a `data` envelope). + const auth = await page.evaluate(async () => (await fetch('/user/auth-methods')).json()); + expect(auth.data.hasPassword).toBe(true); + }); + + test('OIDC setPassword is denied with 403, not a permanent 401 step-up, when the flag is false', async ({ + page, + }) => { + test.skip(allowInitial, 'App booted with the flag true; the denial branch does not apply.'); + + await loginWithKeycloak(page); + + const result = await setPassword(page, 'Test@Pass123!'); + // The account cannot satisfy WEBAUTHN step-up, so the gate falls back to the flag rather than + // returning a permanent 401. With the flag false the fallback denies with a plain 403. + expect(result.status, `setPassword body: ${JSON.stringify(result.body)}`).toBe(403); + // Nail the anti-regression: it must NOT be the passkey-only 401 (JSONResponse code 6), which the + // client would turn into a never-satisfiable step-up prompt for an account with no passkey. + expect(result.status).not.toBe(401); + + // No password was set. + const auth = await page.evaluate(async () => (await fetch('/user/auth-methods')).json()); + expect(auth.data.hasPassword).toBe(false); + }); + + test('a freshly logged-in OIDC user can enroll a first passkey', async ({ page }) => { + // Flag-independent; run it once, on the true (default) boot, to avoid a duplicate run. + test.skip(!allowInitial, 'Runs once on the flag-true boot; skipped on the flag-false boot.'); + + await setupVirtualAuthenticator(page); + await loginWithKeycloak(page); + + // OIDC login stamps an authentication factor (FACTOR_AUTHORIZATION_CODE), which satisfies the + // enrollment gate, so a first passkey can be added right after logging in. + await page.goto('/user/update-user.html'); + await page.evaluate(async () => { + const { registerPasskey } = await import('/js/user/webauthn-register.js'); + await registerPasskey('oidc-first-passkey'); + }); + await page.reload(); + expect((await getCredentialIds(page)).length).toBe(1); + }); +}); diff --git a/playwright/tsconfig.json b/playwright/tsconfig.json index c7e5d7f..87ec7a3 100644 --- a/playwright/tsconfig.json +++ b/playwright/tsconfig.json @@ -18,6 +18,6 @@ "@utils/*": ["src/utils/*"] } }, - "include": ["src/**/*", "tests/**/*", "playwright.config.ts"], + "include": ["src/**/*", "tests/**/*", "playwright.config.ts", "global-setup.ts", "global-teardown.ts"], "exclude": ["node_modules", "dist"] } From 4db4aa5a8721bd55f48217b1d1a0a17aaa399343 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 24 Aug 2026 12:37:43 -0600 Subject: [PATCH 2/3] ci: run OIDC step-up E2E job only on main pushes (#90) The playwright-tests-oidc job is heavier than the others (two app boots plus a Keycloak container), so gate it to pushes on main rather than running it on every pull request. Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt --- .github/workflows/tests.yml | 4 ++++ docs/TESTING.md | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d75fb0c..dce0b4e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -124,6 +124,10 @@ jobs: playwright-tests-oidc: name: Playwright OIDC Step-Up Tests + # Heavier than the other jobs (two app boots plus a Keycloak container), so it runs only on pushes + # to main, not on every pull request. The `push` trigger is already scoped to main; the ref check + # keeps it correct if more push branches are added later. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 30 services: diff --git a/docs/TESTING.md b/docs/TESTING.md index 7afbebd..b72d790 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -174,6 +174,7 @@ app, starts a `mariadb:12.2` service container, installs Playwright, then runs E with `APP_PROFILES=playwright-test` against `chromium` (MFA off), once with `APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on), and once with `APP_PROFILES=local,playwright-test,step-up,step-up-e2e` against `chromium-step-up`. **`playwright-tests-oidc`** -covers the OIDC `setPassword` fallback: it starts the same MariaDB service, and `globalSetup` brings up a -dev-mode Keycloak on the runner, then runs `chromium-step-up-oidc` twice (once per -`allowInitialPasswordSetWithoutStepUp` branch). +covers the OIDC `setPassword` fallback: it starts the same MariaDB service (plus a Mailpit service), and +`globalSetup` brings up a dev-mode Keycloak on the runner, then runs `chromium-step-up-oidc` twice (once per +`allowInitialPasswordSetWithoutStepUp` branch). Because it is heavier (two app boots plus a Keycloak +container), it runs only on pushes to `main`, not on every pull request. From 07e53bf30c55c519a72e8d4842f3ee2a5319e61e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 24 Aug 2026 13:00:51 -0600 Subject: [PATCH 3/3] test: extract shared WebAuthn E2E helpers and harden Keycloak startup Addresses the two low-severity review findings on PR #93 (#90). - Extract setupVirtualAuthenticator, addVirtualAuthenticator, and getCredentialIds into playwright/src/utils/webauthn.ts and export them from utils/index.ts. The step-up-oidc, step-up-flow, and mfa-flow specs now import these instead of each carrying a local copy (mfa-flow previously inlined the authenticator setup). - startKeycloak now removes the container it started if waitForMetadata times out. Playwright's separate globalTeardown is not guaranteed to run when globalSetup throws, so a readiness timeout could otherwise leave the container and ownership marker behind, which the next run's "already running but not answering" guard would reject until a manual docker rm -f. Claude-Session: https://claude.ai/code/session_01EJY7pA4NvY9vJt6CfDBVWt --- playwright/src/utils/index.ts | 2 + playwright/src/utils/keycloak.ts | 13 ++++- playwright/src/utils/webauthn.ts | 57 +++++++++++++++++++ playwright/tests/mfa/mfa-flow.spec.ts | 18 ++---- playwright/tests/step-up/step-up-flow.spec.ts | 45 +-------------- playwright/tests/step-up/step-up-oidc.spec.ts | 34 +---------- 6 files changed, 78 insertions(+), 91 deletions(-) create mode 100644 playwright/src/utils/webauthn.ts diff --git a/playwright/src/utils/index.ts b/playwright/src/utils/index.ts index 6be02b3..a327a06 100644 --- a/playwright/src/utils/index.ts +++ b/playwright/src/utils/index.ts @@ -14,3 +14,5 @@ export { type UnlockUserResponse, type HealthResponse, } from './test-api-client'; + +export { setupVirtualAuthenticator, addVirtualAuthenticator, getCredentialIds } from './webauthn'; diff --git a/playwright/src/utils/keycloak.ts b/playwright/src/utils/keycloak.ts index 1e1a1b3..e90dd1b 100644 --- a/playwright/src/utils/keycloak.ts +++ b/playwright/src/utils/keycloak.ts @@ -138,8 +138,17 @@ export async function startKeycloak(): Promise { fs.mkdirSync(path.dirname(OWNED_MARKER), { recursive: true }); fs.writeFileSync(OWNED_MARKER, CONTAINER); - // Cold start does a Liquibase-free dev boot plus the realm import; allow generous headroom for CI. - await waitForMetadata(120_000); + try { + // Cold start does a Liquibase-free dev boot plus the realm import; allow generous headroom for CI. + await waitForMetadata(120_000); + } catch (err) { + // Readiness timed out on a container this run started. Playwright's separate globalTeardown is not + // guaranteed to run when globalSetup throws, so clean up here rather than leaving the container and + // marker behind (which the "already running but not answering" guard above would then reject on the + // next run, forcing a manual `docker rm -f`). stopKeycloak removes exactly what we own. + await stopKeycloak(); + throw err; + } } /** Stop and remove the Keycloak container, but only if this run started it. */ diff --git a/playwright/src/utils/webauthn.ts b/playwright/src/utils/webauthn.ts new file mode 100644 index 0000000..b8c70ff --- /dev/null +++ b/playwright/src/utils/webauthn.ts @@ -0,0 +1,57 @@ +import type { CDPSession, Page } from '@playwright/test'; + +/** + * Shared CDP WebAuthn virtual-authenticator helpers for the E2E specs. + * + * Chromium's DevTools protocol can host a virtual authenticator that answers create()/get() ceremonies + * automatically (automaticPresenceSimulation + isUserVerified), so passkey flows run headless with no + * human touch. The step-up, MFA, and OIDC specs all need this, so it lives here rather than being copied + * into each spec. + */ + +/** + * Add one virtual authenticator to an already-enabled CDP session. + * + * A second authenticator (transport 'usb') is needed to enroll a second passkey: `excludeCredentials` + * makes the authenticator that already holds a credential decline a repeat enrollment, and Chrome allows + * only one 'internal' (platform) authenticator per environment, so the second credential must come from a + * roaming ('usb') one. + */ +export async function addVirtualAuthenticator( + cdp: CDPSession, + transport: 'internal' | 'usb' = 'internal' +): Promise { + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport, + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); +} + +/** + * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get(), so a passkey can be + * enrolled or asserted without a human touch. Returns the CDP session so callers can add a second + * authenticator (see addVirtualAuthenticator). + */ +export async function setupVirtualAuthenticator(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable'); + await addVirtualAuthenticator(cdp); + return cdp; +} + +/** Read the current credential id list via the management API, using the page's CSRF meta tokens. */ +export async function getCredentialIds(page: Page): Promise { + return page.evaluate(async () => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/webauthn/credentials', { headers: { [csrfHeader]: csrfToken } }); + const creds = await response.json(); + return creds.map((c: { id: string }) => c.id); + }); +} diff --git a/playwright/tests/mfa/mfa-flow.spec.ts b/playwright/tests/mfa/mfa-flow.spec.ts index 492cf0f..da7db27 100644 --- a/playwright/tests/mfa/mfa-flow.spec.ts +++ b/playwright/tests/mfa/mfa-flow.spec.ts @@ -1,4 +1,5 @@ import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures'; +import { setupVirtualAuthenticator } from '../../src/utils'; /** * Full MFA flow E2E test using Chromium's CDP WebAuthn virtual authenticator. @@ -19,20 +20,9 @@ test.describe('MFA Full Flow @mfa-enabled', () => { const user = generateTestUser('mfa-e2e'); cleanupEmails.push(user.email); - // Set up a virtual authenticator before any WebAuthn ceremony. automaticPresenceSimulation - // auto-approves create()/get() prompts so no human touch is needed. - const cdp = await page.context().newCDPSession(page); - await cdp.send('WebAuthn.enable'); - await cdp.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); + // Set up a virtual authenticator before any WebAuthn ceremony. It auto-approves create()/get() + // prompts (automaticPresenceSimulation), so no human touch is needed. + await setupVirtualAuthenticator(page); // Password login leaves the user partially authenticated: PASSWORD satisfied, WEBAUTHN missing. await createAndLoginUser(page, testApiClient, user); diff --git a/playwright/tests/step-up/step-up-flow.spec.ts b/playwright/tests/step-up/step-up-flow.spec.ts index 079b96c..82be9c2 100644 --- a/playwright/tests/step-up/step-up-flow.spec.ts +++ b/playwright/tests/step-up/step-up-flow.spec.ts @@ -1,5 +1,6 @@ import { test, expect, generateTestUser, TestUser } from '../../src/fixtures'; -import type { CDPSession, Page } from '@playwright/test'; +import { setupVirtualAuthenticator, addVirtualAuthenticator, getCredentialIds } from '../../src/utils'; +import type { Page } from '@playwright/test'; /** * WebAuthn step-up (SUF-02) E2E, using Chromium's CDP virtual authenticator. @@ -17,37 +18,6 @@ import type { CDPSession, Page } from '@playwright/test'; * reliance on the TTL elapsing. */ -/** - * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get() so no human touch is - * needed. Mirrors playwright/tests/mfa/mfa-flow.spec.ts. - */ -async function setupVirtualAuthenticator(page: Page): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send('WebAuthn.enable'); - await addVirtualAuthenticator(cdp); - return cdp; -} - -/** - * Add one more virtual authenticator to an enabled CDP session. A second authenticator is needed to enroll a - * second passkey, because `excludeCredentials` makes the authenticator that already holds a credential decline - * a repeat enrollment. - */ -async function addVirtualAuthenticator(cdp: CDPSession, transport: 'internal' | 'usb' = 'internal'): Promise { - // Chrome allows only one 'internal' (platform) authenticator per environment, so a second credential must - // come from a roaming ('usb') authenticator. - await cdp.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport, - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); -} - /** * Register a passkey-only account and enroll its first passkey, leaving the browser session * authenticated with no fresh WEBAUTHN factor. Returns once a passkey is enrolled. @@ -87,17 +57,6 @@ async function getSessionCookie(page: Page): Promise { return session?.value; } -/** Read the current credential id list via the management API. */ -async function getCredentialIds(page: Page): Promise { - return page.evaluate(async () => { - const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; - const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; - const response = await fetch('/user/webauthn/credentials', { headers: { [csrfHeader]: csrfToken } }); - const creds = await response.json(); - return creds.map((c: { id: string }) => c.id); - }); -} - test.describe('WebAuthn Step-Up @step-up-enabled', () => { // Run serially: each test creates a passwordless account, and concurrent inserts into user_account // deadlock in MariaDB under the framework's registration path. Serial execution keeps these diff --git a/playwright/tests/step-up/step-up-oidc.spec.ts b/playwright/tests/step-up/step-up-oidc.spec.ts index cd367e2..384cde6 100644 --- a/playwright/tests/step-up/step-up-oidc.spec.ts +++ b/playwright/tests/step-up/step-up-oidc.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../src/fixtures'; -import type { CDPSession, Page } from '@playwright/test'; +import { setupVirtualAuthenticator, getCredentialIds } from '../../src/utils'; +import type { Page } from '@playwright/test'; /** * Step-up (SUF-02) fallback for OIDC social-login accounts. Follow-up to #75, issue #90. @@ -70,37 +71,6 @@ async function setPassword(page: Page, newPassword: string): Promise<{ status: n }, newPassword); } -/** - * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get(), so a passkey can be - * enrolled without a human touch. Mirrors the helper in step-up-flow.spec.ts. - */ -async function setupVirtualAuthenticator(page: Page): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send('WebAuthn.enable'); - await cdp.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); - return cdp; -} - -/** Read the current credential id list via the management API. */ -async function getCredentialIds(page: Page): Promise { - return page.evaluate(async () => { - const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; - const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; - const response = await fetch('/user/webauthn/credentials', { headers: { [csrfHeader]: csrfToken } }); - const creds = await response.json(); - return creds.map((c: { id: string }) => c.id); - }); -} - test.describe('Step-Up OIDC fallback @step-up-oidc', () => { // Serial: each test logs in as the same Keycloak user, which maps to one local account. Running them // one at a time keeps the per-test reset (below) from racing a concurrent login re-provisioning it.