diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 00000000..34dbdfae --- /dev/null +++ b/.env.e2e @@ -0,0 +1,41 @@ +PROJECT_URL=http://localhost:8887 + +E2E_NOTIFICATION_SMTP_HOST="smtp.lunaris-mail.test" +E2E_NOTIFICATION_SMTP_PORT="587" +E2E_NOTIFICATION_SMTP_USER="e2e_user_7f9a2c" +E2E_NOTIFICATION_SMTP_PASSWORD="uR8vK3mZpQ92xTnB" +E2E_NOTIFICATION_SMTP_FROM="e2e-sender@lunaris-mail.test" +E2E_NOTIFICATION_SMTP_TO="e2e-recipient@lunaris-mail.test" +E2E_NOTIFICATION_SLACK_WEBHOOK="https://hooks.slack.com/services/T00000000/B00000000/AbCdEfGhIjKlMnOpQrStUvWx" +E2E_NOTIFICATION_DISCORD_WEBHOOK="https://discord.com/api/webhooks/123456789012345678/qtY8xKpLmN4vBzR6sWdE9uHaC3fGjT2" +E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN="7352198460:AAHkLm9vPqR2sTuVwXyZ1234567890abcd" +E2E_NOTIFICATION_TELEGRAM_CHAT_ID="-1002847391826" +E2E_NOTIFICATION_TELEGRAM_TOPIC_ID="42" +E2E_NOTIFICATION_GOTIFY_SERVER_URL="https://gotify-e2e.lunaris.test" +E2E_NOTIFICATION_GOTIFY_APP_TOKEN="gfy_8a92f3c1d7e64b0aa51c" +E2E_NOTIFICATION_NTFY_TOPIC="e2e-alerts-7f9a2c" +E2E_NOTIFICATION_NTFY_SERVER_URL="https://ntfy.sh" +E2E_NOTIFICATION_NTFY_TOKEN="tk_e2e_9c7f4a1b6d2e8f0" +E2E_NOTIFICATION_NTFY_USERNAME="ntfy_e2e_user" +E2E_NOTIFICATION_NTFY_PASSWORD="NtFyP4ss_8rQ2zLm" +E2E_NOTIFICATION_WEBHOOK_URL="https://webhook-e2e.lunaris.test/notify" +E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER="X-E2E-Signature" +E2E_NOTIFICATION_WEBHOOK_SECRET="whsec_2f8a9c4e71b6d3a0" + +E2E_STORAGE_AWS_S3_ENDPOINT_URL="https://s3.eu-west-3.amazonaws.com" +E2E_STORAGE_AWS_S3_REGION="eu-west-3" +E2E_STORAGE_AWS_S3_ACCESS_KEY="AKIAE2E7F9A2C4D6B8QX" +E2E_STORAGE_AWS_S3_SECRET_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +E2E_STORAGE_AWS_S3_BUCKET_NAME="e2e-bucket-7f9a2c" +E2E_STORAGE_AWS_S3_PORT="443" + +E2E_STORAGE_R2_ENDPOINT_URL="https://7f9a2c4d6b8q.r2.cloudflarestorage.com" +E2E_STORAGE_R2_REGION="auto" +E2E_STORAGE_R2_ACCESS_KEY="R2E2E7F9A2C4D6B8QX" +E2E_STORAGE_R2_SECRET_KEY="r2sec_8vK3mZpQ92xTnBuR4sWdE9uHaC3fGjT2" +E2E_STORAGE_R2_BUCKET_NAME="r2-e2e-bucket-7f9a2c" +E2E_STORAGE_R2_PORT="443" + +E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID="735219846012-e2e7f9a2c4d6b8q.apps.googleusercontent.com" +E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET="GOCSPX-e2e_8vK3mZpQ92xTnBuR4sWd" +E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID="1E2e7F9a2C4d6B8qXzLmNoPqRsTuVwXyZ" diff --git a/.env.example b/.env.example index 21888ecb..4e10c7a3 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,6 @@ AUTH_OIDC_ROLE_MAP="" AUTH_ALLOW_LINKING=true AUTH_ALLOW_UNLINKING=true - # Social OAuth2 Authentification AUTH_SOCIAL_ID="" AUTH_SOCIAL_TITLE="" @@ -65,5 +64,4 @@ AUTH_PASSKEY_ENABLED=true # Retention RETENTION_CRON="* * * * *" -TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056" - +TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056, http://localhost:3057" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fb7f9707..b8e51c14 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,19 +33,64 @@ jobs: load: true tags: portabase/portabase:test - - name: Run app container + - name: Create shared Docker network run: | - docker run -d --name myapp \ - -p 8887:80 \ - -e NODE_ENV=production \ - -e PROJECT_URL=http://localhost:8887 \ - -e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \ - portabase/portabase:test + docker network inspect portabase >/dev/null 2>&1 || \ + docker network create portabase + + - name: Seed OIDC services + run: make seed-auth + + - name: Wait for OIDC providers + run: | + for i in {1..60}; do + if curl -fsS http://localhost:3056/realms/master/.well-known/openid-configuration >/dev/null; then + echo "Keycloak is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout: Keycloak never became ready" + docker compose -f docker-compose.oidc.yml logs + exit 1 + fi + echo "Waiting for Keycloak... ($i/60)" + sleep 2 + done + + for i in {1..60}; do + if curl -fsS http://localhost:3055/healthz >/dev/null; then + echo "Pocket ID is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout: Pocket ID never became ready" + docker compose -f docker-compose.oidc.yml logs + exit 1 + fi + echo "Waiting for Pocket ID... ($i/60)" + sleep 2 + done + + for i in {1..60}; do + if curl -fsS http://localhost:3057/application/o/portabase/.well-known/openid-configuration >/dev/null; then + echo "Authentik is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout: Authentik never became ready" + docker compose -f docker-compose.oidc.yml logs + exit 1 + fi + echo "Waiting for Authentik... ($i/60)" + sleep 2 + done + + - name: Start E2E app stack + run: docker compose -f docker-compose.prod.yml up -d --no-build - name: Wait for app port to be listening run: | for i in {1..40}; do - if curl -fsS http://localhost:8887/ >/dev/null; then echo "App is responding on port 8887" exit 0 @@ -54,7 +99,7 @@ jobs: sleep 2 done echo "Timeout: app never became ready" - docker logs myapp + docker compose -f docker-compose.prod.yml logs exit 1 - name: Install Playwright browsers @@ -114,4 +159,6 @@ jobs: - name: Cleanup if: always() - run: docker stop myapp && docker rm myapp || true \ No newline at end of file + run: | + docker compose -f docker-compose.prod.yml down --volumes || true + docker compose -f docker-compose.oidc.yml down --volumes || true diff --git a/Makefile b/Makefile index 1dea89d1..0cfedb72 100644 --- a/Makefile +++ b/Makefile @@ -5,58 +5,73 @@ up: seed-keycloak: @[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1) - @docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true - @docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true - @docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true - @docker compose -f docker-compose.func.yml up -d keycloak + @docker compose -f docker-compose.oidc.yml stop keycloak >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml rm -f -s keycloak >/dev/null 2>&1 || true + @docker volume rm portabase-oidc_keycloak-data >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml up -d keycloak @echo "Keycloak seed import triggered from seeds/keycloak/*.json" seed-pocket: @[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1) - @docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true - @docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true - @docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true - @docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null - @docker compose -f docker-compose.func.yml up -d pocket-id + @docker compose -f docker-compose.oidc.yml stop pocket-id >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml rm -f -s pocket-id >/dev/null 2>&1 || true + @docker volume rm portabase-oidc_pocket-id-data >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null + @docker compose -f docker-compose.oidc.yml up -d pocket-id @sleep 2 - @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin + @docker compose -f docker-compose.oidc.yml exec pocket-id ./pocket-id one-time-access-token admin @echo "Pocket ID data restored from seeds/pocket-id/portabase.zip" -seed-auth: seed-keycloak seed-pocket +seed-authentik: + @docker compose -f docker-compose.oidc.yml stop authentik-postgresql authentik-server authentik-worker >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml rm -f -s authentik-postgresql authentik-server authentik-worker >/dev/null 2>&1 || true + @docker volume rm portabase-oidc_authentik-data >/dev/null 2>&1 || true + @docker compose -f docker-compose.oidc.yml up -d authentik-postgresql authentik-server authentik-worker + @docker compose -f docker-compose.oidc.yml run --rm authentik-bootstrap + @echo "Authentik seed/bootstrap completed" + +seed-auth: seed-keycloak seed-pocket seed-authentik pocket-token: - @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin + @docker compose -f docker-compose.oidc.yml exec pocket-id ./pocket-id one-time-access-token admin export-pocket: @echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..." @mkdir -p ./seeds/pocket-id - @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip - @docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip - @docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip + @docker compose -f docker-compose.oidc.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip + @docker compose -f docker-compose.oidc.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip + @docker compose -f docker-compose.oidc.yml exec pocket-id rm /tmp/portabase-export.zip @echo "Pocket ID data exported to seeds/pocket-id/portabase.zip" export-keycloak: @echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..." @mkdir -p ./seeds/keycloak @rm -f ./seeds/keycloak/*.json - @docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 + @docker compose -f docker-compose.oidc.yml stop keycloak >/dev/null 2>&1 @docker rm -f kc-exporter >/dev/null 2>&1 || true - @docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file + @docker compose -f docker-compose.oidc.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file @docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/ @docker rm -f kc-exporter >/dev/null 2>&1 - @docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1 + @docker compose -f docker-compose.oidc.yml start keycloak >/dev/null 2>&1 @echo "Keycloak configuration and users exported to seeds/keycloak/*.json" -end-to-end: +e2e-auto: @echo "Starting E2E testing..." - @docker compose -f docker-compose.e2e.yml up -d - @CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1) - @docker compose -f docker-compose.e2e.yml down --volumes + @docker network inspect portabase >/dev/null 2>&1 || docker network create portabase + @$(MAKE) seed-auth + @docker compose -f docker-compose.prod.yml up -d + @CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.prod.yml down --volumes && docker compose -f docker-compose.oidc.yml down --volumes && exit 1) + @docker compose -f docker-compose.prod.yml down --volumes + @docker compose -f docker-compose.oidc.yml down --volumes @echo "Finished E2E testing successfully." e2e-manual: @echo "Starting E2E testing..." - @docker compose -f docker-compose.e2e.yml up -d - @pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1) - @docker compose -f docker-compose.e2e.yml down --volumes - @echo "Finished E2E testing successfully." \ No newline at end of file + @docker network inspect portabase >/dev/null 2>&1 || docker network create portabase + @docker compose -f e2e/helpers/agent/docker-compose.database.yml up -d + @$(MAKE) seed-auth + @docker compose -f docker-compose.prod.yml up -d + @pnpm playwright test --ui || (docker compose -f docker-compose.prod.yml down --volumes && docker compose -f docker-compose.oidc.yml down --volumes && exit 1) + @docker compose -f docker-compose.prod.yml down --volumes + @docker compose -f docker-compose.oidc.yml down --volumes + @echo "Finished E2E testing successfully." diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml deleted file mode 100644 index d0412db7..00000000 --- a/docker-compose.e2e.yml +++ /dev/null @@ -1,37 +0,0 @@ -services: - app: - build: - context: . - dockerfile: docker/dockerfile/Dockerfile - target: prod - ports: - - '8887:80' - environment: - TZ: "Europe/Paris" - env_file: - - .env - volumes: - - portabase-e2e-data:/data - depends_on: - db: - condition: service_healthy - - db: - image: postgres:18-alpine - ports: - - "5433:5432" - volumes: - - postgres-e2e-data:/var/lib/postgresql/data - environment: - - POSTGRES_DB=devdb - - POSTGRES_USER=devuser - - POSTGRES_PASSWORD=changeme - healthcheck: - test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - portabase-e2e-data: - postgres-e2e-data: diff --git a/docker-compose.func.yml b/docker-compose.func.yml deleted file mode 100644 index 48923880..00000000 --- a/docker-compose.func.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: portabase-dev-func - -services: - keycloak: - image: quay.io/keycloak/keycloak:latest - command: start-dev --import-realm - # environment: - # KC_BOOTSTRAP_ADMIN_USERNAME: admin - # KC_BOOTSTRAP_ADMIN_PASSWORD: admin - ports: - - "3056:8080" - volumes: - - keycloak-data:/opt/keycloak/data - - ./seeds/keycloak:/opt/keycloak/data/import:ro - - ./export:/tmp/export - pocket-id: - image: ghcr.io/pocket-id/pocket-id - restart: unless-stopped - environment: - - APP_URL=http://localhost:3055 - - ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr - ports: - - 3055:1411 - volumes: - - pocket-id-data:/app/data - - ./seeds/pocket-id:/seed:ro - healthcheck: - test: "curl -f http://localhost:1411/healthz" - interval: 1m30s - timeout: 5s - retries: 2 - start_period: 10s -volumes: - keycloak-data: - pocket-id-data: diff --git a/docker-compose.oidc.yml b/docker-compose.oidc.yml new file mode 100644 index 00000000..dd57e594 --- /dev/null +++ b/docker-compose.oidc.yml @@ -0,0 +1,138 @@ +name: portabase-oidc + +services: + + keycloak: + image: quay.io/keycloak/keycloak:latest + restart: unless-stopped + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: bootstrap-admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + ports: + - "3056:8080" + volumes: + - keycloak-data:/opt/keycloak/data + - ./seeds/keycloak:/opt/keycloak/data/import:ro + - ./export:/tmp/export + healthcheck: + test: [ "CMD-SHELL", "bash -ec ' { await expect(page.getByText(agent.bName)).toHaveCount(0); }); - // test("Launch the updated agent", async ({page}) => { - // await page.goto("/dashboard/agents"); - // await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible(); - // await get(page, agent.aName).click(); - // - // await expect(page).toHaveURL(/\/dashboard\/agents\/.+/); - // await expect(page.getByText(agent.aName, {exact: true})).toBeVisible(); - // await expect(page.getByText("Registration & Setup")).toBeVisible(); - // - // const commandInput = page.locator("input[readonly]").first(); - // await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click(); - // const command = await commandInput.inputValue(); - // - // agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-")); - // await createAgentWithDockerDatabases(command, agentWorkspace); - // execSync(`portabase start "${agent.aName}"`, { - // cwd: agentWorkspace, - // stdio: "pipe", - // timeout: 120_000, - // }); - // - // await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000}); - // await expect(page.getByText("Action Required")).toHaveCount(0); - // }); + test("Launch the updated agent", async ({page}) => { + await page.goto("/dashboard/agents"); + await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible(); + await get(page, agent.aName).click(); - test.afterAll(async () => { - if (agentWorkspace) { - try { - execSync(`portabase stop "${agent.aName}"`, { - cwd: agentWorkspace, - stdio: "pipe", - timeout: 30_000, - }); - } catch { - } + await expect(page).toHaveURL(/\/dashboard\/agents\/.+/); + await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible(); + await expect(page.getByText("Registration & Setup")).toBeVisible(); - try { - execSync(`portabase uninstall --force "${agent.aName}"`, { - cwd: agentWorkspace, - stdio: "pipe", - timeout: 30_000, - }); - } catch { - } + const commandInput = page.locator("input[readonly]").last(); + await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click(); + const edgeKey = await commandInput.inputValue(); - fs.rmSync(agentWorkspace, {recursive: true, force: true}); - agentWorkspace = null; - } + await launch(edgeKey) + + await expect(page.getByRole("heading", { name: "Managed Databases" })).toBeVisible(); }); }); \ No newline at end of file diff --git a/e2e/helpers/agent.ts b/e2e/helpers/agent.ts index 565f106f..c10a6c90 100644 --- a/e2e/helpers/agent.ts +++ b/e2e/helpers/agent.ts @@ -1,4 +1,5 @@ import {Page} from "@playwright/test"; +import {execSync} from "node:child_process"; /** @@ -64,3 +65,16 @@ export async function remove(page: Page, name: string) { await page.getByRole("button", {name: /Delete Agent/i}).click(); await page.getByRole("button", {name: "Delete", exact: true}).click(); } + + +export async function launch(edgeKey: string) { + const output = execSync( + `EDGE_KEY=${edgeKey} docker compose -f e2e/helpers/agent/docker-compose.agent-a.yml up -d`, + { + cwd: process.cwd(), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return output; +} \ No newline at end of file diff --git a/e2e/helpers/agent/databases.json b/e2e/helpers/agent/databases.json new file mode 100644 index 00000000..bd596a6e --- /dev/null +++ b/e2e/helpers/agent/databases.json @@ -0,0 +1,154 @@ +{ + "databases": [ + { + "name": "PostgreSQL 18", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-18", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef001" + }, + { + "name": "PostgreSQL 17", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-17", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef002" + }, + { + "name": "PostgreSQL 16", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-16", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef003" + }, + { + "name": "PostgreSQL 15", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-15", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef004" + }, + { + "name": "PostgreSQL 14", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-14", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef005" + }, + { + "name": "PostgreSQL 13", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-13", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef006" + }, + { + "name": "PostgreSQL 12", + "database": "backup_e2e", + "type": "postgresql", + "username": "backup", + "password": "backup", + "port": 5432, + "host": "postgresql-12", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef007" + }, + { + "name": "MySQL 8.4", + "database": "backup_e2e", + "type": "mysql", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mysql-8.4", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef008" + }, + { + "name": "MySQL 8.0", + "database": "backup_e2e", + "type": "mysql", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mysql-8.0", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef009" + }, + { + "name": "MariaDB 11.4", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-11.4", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef010" + }, + { + "name": "MariaDB 11.3", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-11.3", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef011" + }, + { + "name": "MariaDB 11.2", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-11.2", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef012" + }, + { + "name": "MariaDB 11.1", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-11.1", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef013" + }, + { + "name": "MariaDB 11.0", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-11.0", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef014" + }, + { + "name": "MariaDB 10.11", + "database": "backup_e2e", + "type": "mariadb", + "username": "backup", + "password": "backup", + "port": 3306, + "host": "mariadb-10.11", + "generated_id": "9f8a5b18-0f45-4b4c-a821-93d7e46ef015" + } + ] +} \ No newline at end of file diff --git a/e2e/helpers/agent/databases.toml b/e2e/helpers/agent/databases.toml new file mode 100644 index 00000000..339c0492 --- /dev/null +++ b/e2e/helpers/agent/databases.toml @@ -0,0 +1,139 @@ +[[databases]] +name = "MongoDB 8.0 Auth" +database = "backup_e2e" +type = "mongodb" +username = "backup" +password = "backup" +port = 27017 +host = "mongodb-8.0-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef101" + +[[databases]] +name = "MongoDB 8.0 No Auth" +database = "backup_e2e" +type = "mongodb" +username = "" +password = "" +port = 27017 +host = "mongodb-8.0-no-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef102" + +[[databases]] +name = "MongoDB 7.0 Auth" +database = "backup_e2e" +type = "mongodb" +username = "backup" +password = "backup" +port = 27017 +host = "mongodb-7.0-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef103" + +[[databases]] +name = "MongoDB 7.0 No Auth" +database = "backup_e2e" +type = "mongodb" +username = "" +password = "" +port = 27017 +host = "mongodb-7.0-no-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef104" + +[[databases]] +name = "MongoDB 6.0 Auth" +database = "backup_e2e" +type = "mongodb" +username = "backup" +password = "backup" +port = 27017 +host = "mongodb-6.0-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef105" + +[[databases]] +name = "MongoDB 6.0 No Auth" +database = "backup_e2e" +type = "mongodb" +username = "" +password = "" +port = 27017 +host = "mongodb-6.0-no-auth" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef106" + +[[databases]] +name = "Redis 8" +database = "0" +type = "redis" +username = "" +password = "" +port = 6379 +host = "redis-8" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef107" + +[[databases]] +name = "Redis 7" +database = "0" +type = "redis" +username = "" +password = "" +port = 6379 +host = "redis-7" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef108" + +[[databases]] +name = "Valkey 8" +database = "0" +type = "valkey" +username = "" +password = "" +port = 6379 +host = "valkey-8" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef109" + +[[databases]] +name = "Valkey 7" +database = "0" +type = "valkey" +username = "" +password = "" +port = 6379 +host = "valkey-7" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef110" + +[[databases]] +name = "MSSQL 2022" +database = "master" +type = "mssql" +username = "sa" +password = "BackupRootPassw0rd!" +port = 1433 +host = "mssql-2022" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef111" + +[[databases]] +name = "MSSQL 2019" +database = "master" +type = "mssql" +username = "sa" +password = "BackupRootPassw0rd!" +port = 1433 +host = "mssql-2019" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef112" + +[[databases]] +name = "SQLite 3.46" +database = "/data/backup_e2e.db" +type = "sqlite" +username = "" +password = "" +port = 0 +host = "sqlite-3.46" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef113" + +[[databases]] +name = "SQLite 3.45" +database = "/data/backup_e2e.db" +type = "sqlite" +username = "" +password = "" +port = 0 +host = "sqlite-3.45" +generated_id = "9f8a5b18-0f45-4b4c-a821-93d7e46ef114" \ No newline at end of file diff --git a/e2e/helpers/agent/docker-compose.agent-a.yml b/e2e/helpers/agent/docker-compose.agent-a.yml new file mode 100644 index 00000000..e7388a70 --- /dev/null +++ b/e2e/helpers/agent/docker-compose.agent-a.yml @@ -0,0 +1,24 @@ +name: portabase-agent-a + +services: + + app: + container_name: portabase-agent + image: portabase/agent:latest + restart: unless-stopped + volumes: + - ./databases.json:/config/config.json + environment: + LOG: info + TZ: "UTC" + POLLING: 5 + EDGE_KEY: "${EDGE_KEY}" + networks: + - portabase + extra_hosts: + - "localhost:host-gateway" + +networks: + portabase: + name: portabase + external: true \ No newline at end of file diff --git a/e2e/helpers/agent/docker-compose.agent-b.yml b/e2e/helpers/agent/docker-compose.agent-b.yml new file mode 100644 index 00000000..808f313b --- /dev/null +++ b/e2e/helpers/agent/docker-compose.agent-b.yml @@ -0,0 +1,23 @@ +name: portabase-agent-b + +services: + + app: + container_name: portabase-agent + image: portabase/agent:latest + restart: unless-stopped + volumes: + - ./databases.json:/config/config.json + environment: + LOG: info + TZ: "UTC" + POLLING: 5 + DATA_PATH: "/data" + EDGE_KEY: "${EDGE_KEY}" + networks: + - portabase + +networks: + portabase: + name: portabase + external: true \ No newline at end of file diff --git a/e2e/helpers/agent/docker-compose.database.yml b/e2e/helpers/agent/docker-compose.database.yml new file mode 100644 index 00000000..4d02c983 --- /dev/null +++ b/e2e/helpers/agent/docker-compose.database.yml @@ -0,0 +1,390 @@ +name: database + +x-postgresql-common: &postgresql-common + environment: + POSTGRES_DB: backup_e2e + POSTGRES_USER: backup + POSTGRES_PASSWORD: backup + healthcheck: + test: ["CMD-SHELL", "pg_isready -U backup -d backup_e2e"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - portabase + +x-mysql-common: &mysql-common + environment: + MYSQL_DATABASE: backup_e2e + MYSQL_USER: backup + MYSQL_PASSWORD: backup + MYSQL_ROOT_PASSWORD: backup-root + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - portabase + +x-mariadb-common: &mariadb-common + environment: + MARIADB_DATABASE: backup_e2e + MARIADB_USER: backup + MARIADB_PASSWORD: backup + MARIADB_ROOT_PASSWORD: backup-root + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p$$MARIADB_ROOT_PASSWORD"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - portabase + +x-mongodb-auth-common: &mongodb-auth-common + environment: + MONGO_INITDB_ROOT_USERNAME: backup + MONGO_INITDB_ROOT_PASSWORD: backup + MONGO_INITDB_DATABASE: backup_e2e + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh --quiet --username $$MONGO_INITDB_ROOT_USERNAME --password $$MONGO_INITDB_ROOT_PASSWORD --authenticationDatabase admin --eval \"db.runCommand({ ping: 1 }).ok\" | grep 1", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - portabase + +x-mongodb-no-auth-common: &mongodb-no-auth-common + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh --quiet --eval \"db.runCommand({ ping: 1 }).ok\" | grep 1", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - portabase + +x-redis-common: &redis-common + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 1 + networks: + - portabase + +x-valkey-common: &valkey-common + command: ["valkey-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - portabase + +x-mssql-common: &mssql-common + platform: linux/amd64 + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "BackupRootPassw0rd!" + healthcheck: + test: + [ + "CMD-SHELL", + "(test -x /opt/mssql-tools18/bin/sqlcmd && /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $$MSSQL_SA_PASSWORD -Q \"SELECT 1\") || (test -x /opt/mssql-tools/bin/sqlcmd && /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $$MSSQL_SA_PASSWORD -Q \"SELECT 1\")", + ] + interval: 15s + timeout: 10s + retries: 20 + start_period: 40s + networks: + - portabase + +x-sqlite-common: &sqlite-common + command: ["/bin/sh", "-lc", "mkdir -p /data && sqlite3 /data/backup_e2e.db 'SELECT 1;' >/dev/null && tail -f /dev/null"] + healthcheck: + test: ["CMD-SHELL", "sqlite3 /data/backup_e2e.db 'SELECT 1;' | grep 1"] + interval: 10s + timeout: 5s + retries: 1 + networks: + - portabase + +services: + + postgresql-18: + <<: *postgresql-common + image: postgres:18-alpine + expose: + - "5432" + volumes: + - postgresql-18-backup-e2e-data:/var/lib/postgresql/data + + postgresql-17: + <<: *postgresql-common + image: postgres:17-alpine + expose: + - "5432" + volumes: + - postgresql-17-backup-e2e-data:/var/lib/postgresql/data + + postgresql-16: + <<: *postgresql-common + image: postgres:16-alpine + expose: + - "5432" + volumes: + - postgresql-16-backup-e2e-data:/var/lib/postgresql/data + + postgresql-15: + <<: *postgresql-common + image: postgres:15-alpine + expose: + - "5432" + volumes: + - postgresql-15-backup-e2e-data:/var/lib/postgresql/data + + postgresql-14: + <<: *postgresql-common + image: postgres:14-alpine + expose: + - "5432" + volumes: + - postgresql-14-backup-e2e-data:/var/lib/postgresql/data + + postgresql-13: + <<: *postgresql-common + image: postgres:13-alpine + expose: + - "5432" + volumes: + - postgresql-13-backup-e2e-data:/var/lib/postgresql/data + + postgresql-12: + <<: *postgresql-common + image: postgres:12-alpine + expose: + - "5432" + volumes: + - postgresql-12-backup-e2e-data:/var/lib/postgresql/data + + mysql-8.4: + <<: *mysql-common + image: mysql:8.4 + expose: + - "3306" + volumes: + - mysql-8.4-backup-e2e-data:/var/lib/mysql + + mysql-8.0: + <<: *mysql-common + image: mysql:8.0 + expose: + - "3306" + volumes: + - mysql-8.0-backup-e2e-data:/var/lib/mysql + + mariadb-11.4: + <<: *mariadb-common + image: mariadb:11.4 + expose: + - "3306" + volumes: + - mariadb-11.4-backup-e2e-data:/var/lib/mysql + + mariadb-11.3: + <<: *mariadb-common + image: mariadb:11.3 + expose: + - "3306" + volumes: + - mariadb-11.3-backup-e2e-data:/var/lib/mysql + + mariadb-11.2: + <<: *mariadb-common + image: mariadb:11.2 + expose: + - "3306" + volumes: + - mariadb-11.2-backup-e2e-data:/var/lib/mysql + + mariadb-11.1: + <<: *mariadb-common + image: mariadb:11.1 + expose: + - "3306" + volumes: + - mariadb-11.1-backup-e2e-data:/var/lib/mysql + + mariadb-11.0: + <<: *mariadb-common + image: mariadb:11.0 + expose: + - "3306" + volumes: + - mariadb-11.0-backup-e2e-data:/var/lib/mysql + + mariadb-10.11: + <<: *mariadb-common + image: mariadb:10.11 + expose: + - "3306" + volumes: + - mariadb-10.11-backup-e2e-data:/var/lib/mysql + + mongodb-8.0-auth: + <<: *mongodb-auth-common + image: mongo:8.0 + expose: + - "27017" + volumes: + - mongodb-8.0-auth-backup-e2e-data:/data/db + + mongodb-8.0-no-auth: + <<: *mongodb-no-auth-common + image: mongo:8.0 + expose: + - "27017" + volumes: + - mongodb-8.0-no-auth-backup-e2e-data:/data/db + + mongodb-7.0-auth: + <<: *mongodb-auth-common + image: mongo:7.0 + expose: + - "27017" + volumes: + - mongodb-7.0-auth-backup-e2e-data:/data/db + + mongodb-7.0-no-auth: + <<: *mongodb-no-auth-common + image: mongo:7.0 + expose: + - "27017" + volumes: + - mongodb-7.0-no-auth-backup-e2e-data:/data/db + + mongodb-6.0-auth: + <<: *mongodb-auth-common + image: mongo:6.0 + expose: + - "27017" + volumes: + - mongodb-6.0-auth-backup-e2e-data:/data/db + + mongodb-6.0-no-auth: + <<: *mongodb-no-auth-common + image: mongo:6.0 + expose: + - "27017" + volumes: + - mongodb-6.0-no-auth-backup-e2e-data:/data/db + + redis-8: + <<: *redis-common + image: redis:8 + expose: + - "6379" + volumes: + - redis-8-backup-e2e-data:/data + + redis-7: + <<: *redis-common + image: redis:7 + expose: + - "6379" + volumes: + - redis-7-backup-e2e-data:/data + + valkey-8: + <<: *valkey-common + image: valkey/valkey:8 + expose: + - "6379" + volumes: + - valkey-8-backup-e2e-data:/data + + valkey-7: + <<: *valkey-common + image: valkey/valkey:7 + expose: + - "6379" + volumes: + - valkey-7-backup-e2e-data:/data + + mssql-2022: + <<: *mssql-common + image: mcr.microsoft.com/mssql/server:2022-latest + expose: + - "1433" + volumes: + - mssql-2022-backup-e2e-data:/var/opt/mssql + + mssql-2019: + <<: *mssql-common + image: mcr.microsoft.com/mssql/server:2019-latest + expose: + - "1433" + volumes: + - mssql-2019-backup-e2e-data:/var/opt/mssql + + sqlite-3.46: + <<: *sqlite-common + image: keinos/sqlite3:3.46.1 + volumes: + - sqlite-3.46-backup-e2e-data:/data + + sqlite-3.45: + <<: *sqlite-common + image: keinos/sqlite3:3.45.3 + volumes: + - sqlite-3.45-backup-e2e-data:/data + +volumes: + postgresql-18-backup-e2e-data: + postgresql-17-backup-e2e-data: + postgresql-16-backup-e2e-data: + postgresql-15-backup-e2e-data: + postgresql-14-backup-e2e-data: + postgresql-13-backup-e2e-data: + postgresql-12-backup-e2e-data: + mysql-8.4-backup-e2e-data: + mysql-8.0-backup-e2e-data: + mariadb-11.4-backup-e2e-data: + mariadb-11.3-backup-e2e-data: + mariadb-11.2-backup-e2e-data: + mariadb-11.1-backup-e2e-data: + mariadb-11.0-backup-e2e-data: + mariadb-10.11-backup-e2e-data: + mongodb-8.0-auth-backup-e2e-data: + mongodb-8.0-no-auth-backup-e2e-data: + mongodb-7.0-auth-backup-e2e-data: + mongodb-7.0-no-auth-backup-e2e-data: + mongodb-6.0-auth-backup-e2e-data: + mongodb-6.0-no-auth-backup-e2e-data: + redis-8-backup-e2e-data: + redis-7-backup-e2e-data: + valkey-8-backup-e2e-data: + valkey-7-backup-e2e-data: + mssql-2022-backup-e2e-data: + mssql-2019-backup-e2e-data: + sqlite-3.46-backup-e2e-data: + sqlite-3.45-backup-e2e-data: + +networks: + portabase: + name: portabase + external: true \ No newline at end of file diff --git a/e2e/helpers/oidc.ts b/e2e/helpers/oidc.ts new file mode 100644 index 00000000..f44f4a16 --- /dev/null +++ b/e2e/helpers/oidc.ts @@ -0,0 +1,165 @@ +import {expect, Page} from "@playwright/test"; +import {execSync} from "node:child_process"; + + +export const oidcProviders: Array<{ id: string, title: string }> = [ + {id: "authentik", title: "Authentik"}, + {id: "pocket-id", title: "Pocket ID"}, + {id: "keycloak", title: "Keycloak"}, +] + + +export async function loginWithOidc(page: Page, providerId) { + await page.goto("/login"); + + const provider = oidcProviders.find((entry) => entry.id === providerId); + if (!provider) throw new Error(`Unknown OIDC provider: ${providerId}`) + + await expect(page.getByRole("button", {name: provider.title})).toBeVisible(); + + const authorizationUrl = await getOidcAuthorizationUrl(page, providerId); + await page.goto(authorizationUrl); + + switch (providerId) { + case "pocket-id": + await loginWithPocketId(page) + return + case "authentik": + await loginWithAuthentik(page) + return + case "keycloak": + await loginWithKeycloak(page) + return + } + + // if (providerId === "pocket-id") { + // await loginWithPocketId(page); + // return; + // } + // + // const authorizationUrl = await getOidcAuthorizationUrl(page, providerId); + // await page.goto(authorizationUrl); + // + // if (providerId === "authentik") { + // await loginWithAuthentik(page); + // return; + // } + // + // await loginWithKeycloak(page); +} + + + + + + + + + +const AUTHENTIK_USER = "admin@example.com"; +const AUTHENTIK_PASSWORD = "testPASS123456!"; + +const KEYCLOAK_USER = "admin"; +const KEYCLOAK_PASSWORD = "admin"; + +const oidcHostRewrites: Record = { + "http://authentik-server:9000": "http://localhost:3057", + "http://keycloak:8080": "http://localhost:3056", + "http://pocket-id:1411": "http://localhost:3055", +}; + +function toBrowserReachableUrl(url: string) { + let browserUrl = url; + + for (const [internalOrigin, browserOrigin] of Object.entries(oidcHostRewrites)) { + browserUrl = browserUrl.replace(internalOrigin, browserOrigin); + } + + return browserUrl; +} + +async function getOidcAuthorizationUrl(page: Page, providerId) { + for (let attempt = 0; attempt < 3; attempt++) { + const result = await page.evaluate(async ({providerId}) => { + const response = await fetch("/api/auth/sign-in/sso", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + providerId, + providerType: "oidc", + callbackURL: "/dashboard", + }), + }); + + const data = await response.json().catch(() => ({})); + return { + ok: response.ok, + status: response.status, + data, + }; + }, {providerId}); + + if (result.ok && result.data?.url) { + return toBrowserReachableUrl(result.data.url); + } + + if (result.status !== 429 || attempt === 2) { + throw new Error(`Failed to initiate ${providerId} OIDC sign-in: ${JSON.stringify(result)}`); + } + + await page.waitForTimeout(2_000); + } + throw new Error(`Failed to initiate ${providerId} OIDC sign-in after retries.`); +} + +async function loginWithAuthentik(page: Page) { + const usernameField = page.locator('input[name="uidField"], input[type="text"], input[type="email"]').first(); + await expect(usernameField).toBeVisible(); + await usernameField.fill(AUTHENTIK_USER); + await usernameField.press("Enter").catch(() => undefined); + if (await page.getByLabel(/password/i).count() === 0) { + await page.getByRole("button", {name: /log in|sign in|continue/i}).click(); + } + + const passwordField = page.locator('input[name="password"], input[type="password"]').first(); + await expect(passwordField).toBeVisible(); + await passwordField.fill(AUTHENTIK_PASSWORD); + await page.getByRole("button", {name: /log in|sign in|continue/i}).click(); +} + +async function loginWithKeycloak(page: Page) { + await page.locator('input[name="username"], #username').fill(KEYCLOAK_USER); + await page.locator('input[name="password"], #password').fill(KEYCLOAK_PASSWORD); + await page.locator('input[type="submit"], #kc-login').click(); +} + +function getPocketIdAccessUrl() { + const output = execSync( + "docker compose -f docker-compose.oidc.yml exec -T pocket-id ./pocket-id one-time-access-token admin", + { + cwd: process.cwd(), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const match = output.match(/https?:\/\/\S+/); + if (!match) { + throw new Error(`Failed to extract Pocket ID access URL from output:\n${output}`); + } + + return match[0]; +} + +async function loginWithPocketId(page: Page) { + // const accessUrl = getPocketIdAccessUrl(); + // const authorizationUrl = await getOidcAuthorizationUrl(page, "pocket-id"); + + // await page.goto(authorizationUrl); + // await page.goto(accessUrl); + // await page.goto(authorizationUrl); + await page.waitForURL(/\/dashboard\/home(?:\?.*)?$/); +} + diff --git a/e2e/oidc.spec.ts b/e2e/oidc.spec.ts new file mode 100644 index 00000000..9b9ce3b0 --- /dev/null +++ b/e2e/oidc.spec.ts @@ -0,0 +1,20 @@ +import {expect, test} from "@playwright/test"; +import {logout} from "./helpers/auth"; +import {loginWithOidc, oidcProviders} from "./helpers/oidc"; + + +test.describe.serial("OIDC auth", () => { + for (const provider of oidcProviders) { + test(`OIDC login/logout with ${provider.title}`, async ({page}) => { + + await loginWithOidc(page, provider.id); + + await expect(page).toHaveURL(/\/dashboard\/home(?:\?.*)?$/); + await expect(page.getByRole("link", {name: "Logo Portabase"})).toBeVisible(); + + await logout(page); + + await expect(page).toHaveURL(/\/login(?:\?.*)?$/); + }); + } +}); diff --git a/playwright.config.ts b/playwright.config.ts index cb09219d..e0490064 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,8 @@ import path from 'path'; * Read environment variables from file. * https://github.com/motdotla/dotenv */ -dotenv.config({ path: path.resolve(__dirname, '.env') }); +dotenv.config({ path: path.resolve(__dirname, '.env.e2e') }); + /** * See https://playwright.dev/docs/test-configuration. @@ -43,10 +44,15 @@ export default defineConfig({ testMatch: '**/auth.spec.ts', dependencies: ['setup'], }, + { + name: 'oidc', + testMatch: '**/oidc.spec.ts', + dependencies: ['auth'], + }, { name: 'access-management', testMatch: '**/access-management.spec.ts', - dependencies: ['auth'], + dependencies: ['oidc'], }, { name: 'notification', @@ -63,11 +69,10 @@ export default defineConfig({ testMatch: '**/agent.spec.ts', dependencies: ['access-management'], }, - { name: 'project', testMatch: '**/project.spec.ts', - dependencies: ['access-management'], + dependencies: ['agent'], }, { name: 'cleanup', diff --git a/seeds/authentik/README.md b/seeds/authentik/README.md new file mode 100644 index 00000000..7a0b6243 --- /dev/null +++ b/seeds/authentik/README.md @@ -0,0 +1,62 @@ +# Authentik seed contract + +This directory defines the deterministic Authentik seed used by the local OIDC stack in `docker-compose.oidc.yml`. + +## Bootstrap contract + +The compose stack must provide these first-start bootstrap values on the `authentik-worker` container: + +- `AUTHENTIK_BOOTSTRAP_PASSWORD` +- `AUTHENTIK_BOOTSTRAP_TOKEN` +- `AUTHENTIK_BOOTSTRAP_EMAIL` + +Per Authentik's automated install flow, those values are only read on the first startup of a fresh Authentik database volume. + +## Mounted assets + +- `blueprints/portabase-oidc.yaml` + - mounted into `/blueprints/portabase-oidc.yaml` + - auto-discovered by the Authentik worker + - kept as an in-repo reference blueprint for the intended provider/application shape +- `bootstrap.sh` + - executed by the dedicated `authentik-bootstrap` compose service + - waits for the server and required default Authentik objects + - grants the bootstrap admin deterministic `authentik Admins` membership from inside Authentik + - resolves the required default flow, scope-mapping, and signing-key objects from inside Authentik with `ak shell` + - then reconciles the deterministic Portabase seed objects through Authentik's ORM before proving the public discovery URL over HTTP + +## Seeded objects + +The bootstrap flow is expected to reconcile, idempotently: + +- an OAuth2/OIDC provider named `portabase` +- an application named `Portabase` with slug `portabase` +- a confidential client id `portabase` +- a client secret `portabase-e2e-secret` +- a strict redirect URI `http://localhost:8887/api/auth/sso/callback/authentik` +- an internal test user: + - username: `admin@example.com` + - email: `admin@example.com` + - password: `testPASS123456!` +- an `admin` group containing the test user + +## Expected runtime outputs + +After a fresh `docker compose -f docker-compose.oidc.yml up`, the seeded Authentik instance should expose: + +- `http://localhost:3057/-/health/live/` +- `http://localhost:3057/application/o/portabase/.well-known/openid-configuration` + +That discovery document is the canonical proof that the bootstrap completed successfully. The runtime source of truth is `bootstrap.sh`, which first normalizes the bootstrap admin's permissions inside Authentik and then reconciles the provider, application, user, and group directly inside Authentik even if blueprint auto-application semantics or bootstrap-token permissions change between Authentik releases. + +## Portabase env contract + +This seed is aligned with the current E2E environment: + +- `AUTH_OIDC_AUTHENTIK_ID=authentik` +- `AUTH_OIDC_AUTHENTIK_CLIENT=portabase` +- `AUTH_OIDC_AUTHENTIK_SECRET=portabase-e2e-secret` +- `AUTH_OIDC_AUTHENTIK_DISCOVERY_ENDPOINT=http://authentik-server:9000/application/o/portabase/.well-known/openid-configuration` +- `AUTH_OIDC_AUTHENTIK_HOST=authentik-server:9000` +- `AUTH_OIDC_AUTHENTIK_SCOPES=openid profile email` +- `AUTH_OIDC_AUTHENTIK_ALLOWED_GROUP=admin` diff --git a/seeds/authentik/blueprints/portabase-oidc.yaml b/seeds/authentik/blueprints/portabase-oidc.yaml new file mode 100644 index 00000000..0ec066ca --- /dev/null +++ b/seeds/authentik/blueprints/portabase-oidc.yaml @@ -0,0 +1,44 @@ +# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json +version: 1 +metadata: + name: portabase-oidc + labels: + blueprints.goauthentik.io/instantiate: "false" + blueprints.goauthentik.io/description: "Reference Portabase OIDC shape; the deterministic runtime seed is applied by seeds/authentik/bootstrap.sh." +entries: + - model: authentik_providers_oauth2.oauth2provider + state: present + identifiers: + name: portabase + attrs: + name: portabase + authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + client_type: confidential + client_id: portabase + client_secret: portabase-e2e-secret + access_code_validity: minutes=1 + access_token_validity: hours=1 + refresh_token_validity: days=7 + include_claims_in_id_token: true + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + redirect_uris: + - matching_mode: strict + url: http://localhost:8887/api/auth/sso/callback/authentik + sub_mode: user_email + issuer_mode: global + + - model: authentik_core.application + state: present + identifiers: + slug: portabase + attrs: + name: Portabase + slug: portabase + policy_engine_mode: any + provider: !Find [authentik_providers_oauth2.oauth2provider, [name, portabase]] diff --git a/seeds/authentik/bootstrap.sh b/seeds/authentik/bootstrap.sh new file mode 100644 index 00000000..5a1fd878 --- /dev/null +++ b/seeds/authentik/bootstrap.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +python - <<'PY' +import os +import sys +import time +import urllib.error +import urllib.request + +BASE_URL = os.environ.get("AUTHENTIK_BASE_URL", "http://authentik-server:9000") +LIVE_URL = os.environ.get("AUTHENTIK_LIVE_URL", f"{BASE_URL}/-/health/live/") +ATTEMPTS = int(os.environ.get("AUTHENTIK_BOOTSTRAP_ATTEMPTS", "90")) +SLEEP_SECONDS = int(os.environ.get("AUTHENTIK_BOOTSTRAP_SLEEP_SECONDS", "2")) + + +def wait_for_live() -> None: + for attempt in range(1, ATTEMPTS + 1): + try: + with urllib.request.urlopen(LIVE_URL, timeout=10) as response: + if response.status == 200: + print("Authentik live health is reachable") + return + except Exception: + pass + print(f"Waiting for Authentik live health ({attempt}/{ATTEMPTS})") + time.sleep(SLEEP_SECONDS) + raise SystemExit(f"Timed out waiting for Authentik live health at {LIVE_URL}") + + +wait_for_live() +PY + +ak shell -c "import os; from authentik.core.models import Group, User; user = User.objects.get(email=os.environ['AUTHENTIK_BOOTSTRAP_EMAIL']); group = Group.objects.get(name='authentik Admins'); group.users.add(user); print(f'Granted {user.username} authentik Admins membership')" >/dev/null + +BOOTSTRAP_ATTEMPTS="${AUTHENTIK_BOOTSTRAP_ATTEMPTS:-90}" +BOOTSTRAP_SLEEP_SECONDS="${AUTHENTIK_BOOTSTRAP_SLEEP_SECONDS:-2}" + +reconcile_seed_objects() { + attempt=1 + while [ "$attempt" -le "$BOOTSTRAP_ATTEMPTS" ]; do + if ak shell -c "import os; from authentik.core.models import Application, Group, User; from authentik.crypto.models import CertificateKeyPair; from authentik.flows.models import Flow; from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping; bootstrap_admin = User.objects.get(email=os.environ['AUTHENTIK_BOOTSTRAP_EMAIL']); authentik_admins = Group.objects.get(name='authentik Admins'); authentik_admins.users.add(bootstrap_admin); user, user_created = User.objects.get_or_create(username=os.environ['AUTHENTIK_TEST_USER_USERNAME'], defaults={'email': os.environ['AUTHENTIK_TEST_USER_EMAIL'], 'name': os.environ['AUTHENTIK_TEST_USER_NAME']}); user.email = os.environ['AUTHENTIK_TEST_USER_EMAIL']; user.name = os.environ['AUTHENTIK_TEST_USER_NAME']; user.is_active = True; user.save(); user.set_password(os.environ['AUTHENTIK_TEST_USER_PASSWORD']); user.save(); group, group_created = Group.objects.get_or_create(name=os.environ['AUTHENTIK_TEST_GROUP']); group.is_superuser = False; group.save(); group.users.add(user); auth_flow = Flow.objects.get(slug='default-authentication-flow'); authorization_flow = Flow.objects.get(slug='default-provider-authorization-implicit-consent'); invalidation_flow = Flow.objects.get(slug='default-provider-invalidation-flow'); openid_scope = ScopeMapping.objects.get(scope_name='openid'); profile_scope = ScopeMapping.objects.get(scope_name='profile'); email_scope = ScopeMapping.objects.get(scope_name='email'); signing_key = CertificateKeyPair.objects.get(name='authentik Self-signed Certificate'); provider, provider_created = OAuth2Provider.objects.update_or_create(name=os.environ['AUTHENTIK_PROVIDER_NAME'], defaults={'authentication_flow': auth_flow, 'authorization_flow': authorization_flow, 'invalidation_flow': invalidation_flow, 'client_type': 'confidential', 'client_id': os.environ['AUTHENTIK_CLIENT_ID'], 'client_secret': os.environ['AUTHENTIK_CLIENT_SECRET'], 'access_code_validity': 'minutes=1', 'access_token_validity': 'hours=1', 'refresh_token_validity': 'days=7', 'include_claims_in_id_token': True, 'signing_key': signing_key, 'sub_mode': 'user_email', 'issuer_mode': 'global', '_redirect_uris': [{'matching_mode': 'strict', 'url': os.environ['AUTHENTIK_REDIRECT_URI']}],}); provider.property_mappings.set([openid_scope, profile_scope, email_scope]); application, application_created = Application.objects.update_or_create(slug=os.environ['AUTHENTIK_APPLICATION_SLUG'], defaults={'name': os.environ['AUTHENTIK_APPLICATION_NAME'], 'provider': provider.provider_ptr, 'policy_engine_mode': 'any'}); print(f\"Reconciled Authentik seed user={user.username} group={group.name} provider={provider.name} application={application.slug}\")" >/dev/null 2>&1; then + return 0 + fi + echo "Waiting for Authentik seed reconciliation (${attempt}/${BOOTSTRAP_ATTEMPTS})" + attempt=$((attempt + 1)) + sleep "$BOOTSTRAP_SLEEP_SECONDS" + done + return 1 +} + +reconcile_seed_objects + +python - <<'PY' +import json +import os +import time +import urllib.error +import urllib.request + +BASE_URL = os.environ.get("AUTHENTIK_BASE_URL", "http://authentik-server:9000") +DISCOVERY_PATH = os.environ.get("AUTHENTIK_DISCOVERY_PATH", "/application/o/portabase/.well-known/openid-configuration") +DISCOVERY_URL = os.environ.get("AUTHENTIK_DISCOVERY_URL", f"{BASE_URL}{DISCOVERY_PATH}") +ATTEMPTS = int(os.environ.get("AUTHENTIK_BOOTSTRAP_ATTEMPTS", "90")) +SLEEP_SECONDS = int(os.environ.get("AUTHENTIK_BOOTSTRAP_SLEEP_SECONDS", "2")) + + +def http_request(path: str): + url = f"{BASE_URL}{path}" + request = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(request, timeout=15) as response: + body = response.read().decode("utf-8") + return response.status, body + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8") + return exc.code, body + +def wait_for_discovery() -> dict: + for attempt in range(1, ATTEMPTS + 1): + status, body = http_request(DISCOVERY_PATH) + if status == 200: + parsed = json.loads(body) + if "issuer" in parsed and "authorization_endpoint" in parsed: + print(f"Portabase discovery document is reachable at {DISCOVERY_URL}") + return parsed + print(f"Waiting for Portabase discovery document ({attempt}/{ATTEMPTS})") + time.sleep(SLEEP_SECONDS) + raise SystemExit(f"Timed out waiting for discovery document at {DISCOVERY_URL}") +wait_for_discovery() + +print(f"Authentik bootstrap verification completed at {DISCOVERY_URL}") +PY