diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..882656b5 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,39 @@ +# OIDC SSO end-to-end tests + +Browser E2E that proves the "Log in with SSO" flow works against the shipped +uvicorn/ASGI server (not just the Django test client). Everything runs in +containers, so nothing is installed on the host. + +## Stack + +`docker-compose.e2e.yml` brings up: + +- **tubearchivist** — the app built from this repo (`TA_LOGIN_AUTH_MODE=oidc_local`) +- **archivist-es / archivist-redis** — required backing services +- **mock-oidc** — `navikt/mock-oauth2-server`, a real OIDC provider (discovery, + authorize form, token, jwks) that accepts any client/redirect with no config +- **e2e-tests** — Playwright (profile `test`), a container on the same network + so all OIDC endpoints use docker service names (no localhost host-split) + +## Run + +```bash +cd e2e + +# 1. bring up the app and wait for health +docker compose -f docker-compose.e2e.yml up -d --wait + +# 2. run the browser test +docker compose -f docker-compose.e2e.yml --profile test up \ + --abort-on-container-exit --exit-code-from e2e-tests e2e-tests + +# 3. tear down +docker compose -f docker-compose.e2e.yml --profile test down -v +``` + +## Real Authentik (fidelity run) + +`mock-oidc` is intentionally swappable. For a high-fidelity run against real +Authentik, see `docker-compose.authentik.yml` (Authentik server + worker + +postgres + redis, provisioned by a bootstrap blueprint). Point the same +`TA_OIDC_*` endpoints at the Authentik service and re-run step 2. diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml new file mode 100644 index 00000000..1cdcc20c --- /dev/null +++ b/e2e/docker-compose.e2e.yml @@ -0,0 +1,109 @@ +# End-to-end OIDC SSO test stack. +# +# Everything runs on one docker network so browser-facing and backend-facing +# OIDC endpoints share identical hostnames (service names) - this sidesteps the +# classic "issuer host differs between browser and app" problem you hit with +# OIDC behind docker. The Playwright runner is itself a container on the network +# (profile: test), so "localhost" is never involved in the flow. +# +# docker compose -f docker-compose.e2e.yml up -d --wait # bring up the app +# docker compose -f docker-compose.e2e.yml --profile test up \ +# --abort-on-container-exit e2e-tests # run the browser E2E +# +# mock-oidc is navikt/mock-oauth2-server: a real OIDC provider (discovery, +# authorize form, token, jwks) that accepts any client/redirect - zero config. +# Swap it for real Authentik with docker-compose.authentik.yml for fidelity. + +services: + tubearchivist: + image: ta-oidc:e2e + build: + context: .. + args: + INSTALL_DEBUG: "1" + environment: + - ES_URL=http://archivist-es:9200 + - REDIS_CON=redis://archivist-redis:6379 + - HOST_UID=0 + - HOST_GID=0 + - TA_HOST=http://tubearchivist:8000 + - TA_USERNAME=tubearchivist + - TA_PASSWORD=verysecret + - ELASTIC_PASSWORD=verysecret + - TZ=UTC + # --- OIDC / SSO --- + - TA_LOGIN_AUTH_MODE=oidc_local + - TA_OIDC_CLIENT_ID=tubearchivist + - TA_OIDC_CLIENT_SECRET=tubearchivist-secret + - TA_OIDC_AUTHORIZATION_ENDPOINT=http://mock-oidc:8080/default/authorize + - TA_OIDC_TOKEN_ENDPOINT=http://mock-oidc:8080/default/token + - TA_OIDC_USER_ENDPOINT=http://mock-oidc:8080/default/userinfo + - TA_OIDC_JWKS_ENDPOINT=http://mock-oidc:8080/default/jwks + - TA_OIDC_ADMIN_GROUP=tubearchivist-admins + - TA_OIDC_BUTTON_LABEL=Log in with SSO + ports: + - 8000:8000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health/"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 60s + depends_on: + archivist-es: + condition: service_healthy + archivist-redis: + condition: service_started + mock-oidc: + condition: service_started + + archivist-redis: + image: redis + expose: + - "6379" + + archivist-es: + # official multi-arch image: runs arm64-native here (the bundled + # bbilly1/tubearchivist-es is amd64-only and dies under qemu: no seccomp). + # TA uses no ES plugins (no ICU), so stock ES is sufficient. + image: docker.elastic.co/elasticsearch/elasticsearch:8.19.0 + environment: + - "ELASTIC_PASSWORD=verysecret" + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" + - "xpack.security.enabled=true" + - "discovery.type=single-node" + - "path.repo=/usr/share/elasticsearch/data/snapshot" + ulimits: + memlock: + soft: -1 + hard: -1 + expose: + - "9200" + healthcheck: + test: + ["CMD-SHELL", "curl -s -u elastic:verysecret http://localhost:9200/_cluster/health | grep -q '\"status\"'"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + mock-oidc: + image: ghcr.io/navikt/mock-oauth2-server:2.1.10 + expose: + - "8080" + environment: + - LOG_LEVEL=INFO + + e2e-tests: + image: mcr.microsoft.com/playwright:v1.49.0-noble + profiles: ["test"] + depends_on: + tubearchivist: + condition: service_healthy + environment: + - TA_BASE_URL=http://tubearchivist:8000 + - CI=1 + volumes: + - ./playwright:/work + working_dir: /work + command: sh -c "npm install --no-audit --no-fund && npx playwright test" diff --git a/e2e/playwright/.gitignore b/e2e/playwright/.gitignore new file mode 100644 index 00000000..dbd64df8 --- /dev/null +++ b/e2e/playwright/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/e2e/playwright/package-lock.json b/e2e/playwright/package-lock.json new file mode 100644 index 00000000..78e0678d --- /dev/null +++ b/e2e/playwright/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "ta-oidc-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ta-oidc-e2e", + "devDependencies": { + "@playwright/test": "1.49.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz", + "integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", + "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", + "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/playwright/package.json b/e2e/playwright/package.json new file mode 100644 index 00000000..c42a80d4 --- /dev/null +++ b/e2e/playwright/package.json @@ -0,0 +1,8 @@ +{ + "name": "ta-oidc-e2e", + "private": true, + "description": "Browser E2E for TubeArchivist OIDC SSO login", + "devDependencies": { + "@playwright/test": "1.49.0" + } +} diff --git a/e2e/playwright/playwright.config.ts b/e2e/playwright/playwright.config.ts new file mode 100644 index 00000000..ab9b80fa --- /dev/null +++ b/e2e/playwright/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + expect: { timeout: 15_000 }, + retries: 0, + reporter: [['list']], + use: { + baseURL: process.env.TA_BASE_URL || 'http://localhost:8000', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/e2e/playwright/tests/sso.spec.ts b/e2e/playwright/tests/sso.spec.ts new file mode 100644 index 00000000..a35bacfa --- /dev/null +++ b/e2e/playwright/tests/sso.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +const SSO_LABEL = process.env.OIDC_LABEL || 'Log in with SSO'; + +// Drives the full OIDC redirect flow against the running app + mock IdP: +// TA login page -> click SSO -> mock-oauth2-server login form -> +// redirect back to TA callback -> Django session -> authenticated. +// This exercises mozilla-django-oidc under the shipped uvicorn/ASGI server, +// the exact path the old header-forwardauth attempt could not authenticate on. +test('OIDC SSO logs the user in and maps the admin group', async ({ page }) => { + page.on('console', m => console.log(`[browser] ${m.text()}`)); + page.on('pageerror', e => console.log(`[pageerror] ${e.message}`)); + + await page.goto('/login'); + + const ssoButton = page.getByRole('button', { name: SSO_LABEL }); + await expect(ssoButton).toBeVisible(); + + await Promise.all([ + page.waitForURL(/mock-oidc:8080/, { timeout: 20_000 }), + ssoButton.click(), + ]); + + // mock-oauth2-server interactive login: subject + optional claims JSON + await page.locator('input[name="username"]').fill('e2euser'); + await page + .locator('textarea[name="claims"]') + .fill(JSON.stringify({ groups: ['tubearchivist-admins'] })); + + await Promise.all([ + page.waitForURL(url => url.host.includes('tubearchivist'), { + timeout: 20_000, + }), + page.locator('input[type="submit"][value="Sign-in"]').click(), + ]); + + // Session established: the authenticated account API returns the SSO user, + // promoted to superuser via the groups claim. + await expect + .poll(async () => (await page.request.get('/api/user/account/')).status(), { + timeout: 20_000, + }) + .toBe(200); + + const account = await (await page.request.get('/api/user/account/')).json(); + expect(account.name).toBe('e2euser'); + expect(account.is_superuser).toBe(true); +});