feat: adding e2e tests for launching agent.

This commit is contained in:
killian-larcher
2026-05-21 23:46:03 +02:00
parent 55be9374e1
commit 252aab9b90
22 changed files with 1446 additions and 182 deletions
+41
View File
@@ -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"
+1 -3
View File
@@ -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"
+57 -10
View File
@@ -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
run: |
docker compose -f docker-compose.prod.yml down --volumes || true
docker compose -f docker-compose.oidc.yml down --volumes || true
+41 -26
View File
@@ -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."
@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."
-37
View File
@@ -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:
-35
View File
@@ -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:
+138
View File
@@ -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 '</dev/tcp/127.0.0.1/8080'" ]
interval: 30s
timeout: 5s
retries: 10
start_period: 30s
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
authentik-server:
image: ghcr.io/goauthentik/server:2026.2.3
restart: unless-stopped
command: server
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: changemechangemechangemechangeme
ports:
- "3057:9000"
depends_on:
authentik-postgresql:
condition: service_healthy
healthcheck:
test: [ "CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:9000/-/health/live/')\"" ]
interval: 30s
timeout: 5s
retries: 10
start_period: 30s
authentik-postgresql:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: authentik
volumes:
- authentik-data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U authentik -d authentik" ]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
authentik-worker:
image: ghcr.io/goauthentik/server:2026.2.3
restart: unless-stopped
command: worker
user: root
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: changemechangemechangemechangeme
AUTHENTIK_BOOTSTRAP_PASSWORD: portabase-bootstrap-password
AUTHENTIK_BOOTSTRAP_TOKEN: portabase-bootstrap-token
AUTHENTIK_BOOTSTRAP_EMAIL: akadmin@portabase.local
volumes:
- ./seeds/authentik/blueprints/portabase-oidc.yaml:/blueprints/portabase-oidc.yaml:ro
shm_size: 512mb
depends_on:
authentik-postgresql:
condition: service_healthy
authentik-bootstrap:
image: ghcr.io/goauthentik/server:2026.2.3
entrypoint: [ "/bin/sh", "/seeds/bootstrap.sh" ]
environment:
AUTHENTIK_BASE_URL: http://authentik-server:9000
AUTHENTIK_API_TOKEN: portabase-bootstrap-token
AUTHENTIK_DISCOVERY_PATH: /application/o/portabase/.well-known/openid-configuration
AUTHENTIK_SECRET_KEY: changemechangemechangemechangeme
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_BOOTSTRAP_EMAIL: akadmin@portabase.local
AUTHENTIK_TEST_USER_USERNAME: admin@example.com
AUTHENTIK_TEST_USER_EMAIL: admin@example.com
AUTHENTIK_TEST_USER_NAME: Portabase Admin
AUTHENTIK_TEST_USER_PASSWORD: testPASS123456!
AUTHENTIK_TEST_GROUP: admin
AUTHENTIK_PROVIDER_NAME: portabase
AUTHENTIK_APPLICATION_NAME: Portabase
AUTHENTIK_APPLICATION_SLUG: portabase
AUTHENTIK_CLIENT_ID: portabase
AUTHENTIK_CLIENT_SECRET: portabase-e2e-secret
AUTHENTIK_REDIRECT_URI: http://localhost:8887/api/auth/sso/callback/authentik
volumes:
- ./seeds/authentik:/seeds:ro
depends_on:
authentik-server:
condition: service_healthy
authentik-worker:
condition: service_healthy
volumes:
keycloak-data:
pocket-id-data:
authentik-data:
+15 -14
View File
@@ -1,35 +1,36 @@
name: portabase-prod
services:
app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-data:/data
- portabase-prod-data:/data
depends_on:
db:
condition: service_healthy
container_name: portabase-app-prod
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
test: [ "CMD-SHELL", "curl -f http://localhost/api/health" ]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
extra_hosts:
- "localhost:host-gateway"
db:
image: postgres:16-alpine
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- portabase-prod-postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
@@ -41,5 +42,5 @@ services:
retries: 5
volumes:
portabase-data:
postgres-data:
portabase-prod-data:
portabase-prod-postgres-data:
+2 -2
View File
@@ -6,7 +6,7 @@ services:
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- portabase-dev-postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
@@ -42,4 +42,4 @@ services:
- "1025:1025"
volumes:
postgres-data:
portabase-dev-postgres-data:
+14 -51
View File
@@ -1,10 +1,5 @@
import {expect, test} from "@playwright/test";
import {execSync} from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
import {create, edit, get, remove} from "./helpers/agent";
import {create, edit, get, launch, remove} from "./helpers/agent";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const agent = {
@@ -64,53 +59,21 @@ test.describe.serial(() => {
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();
});
});
+14
View File
@@ -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;
}
+154
View File
@@ -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"
}
]
}
+139
View File
@@ -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"
@@ -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
@@ -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
@@ -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
+165
View File
@@ -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<string, string> = {
"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(?:\?.*)?$/);
}
+20
View File
@@ -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(?:\?.*)?$/);
});
}
});
+9 -4
View File
@@ -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',
+62
View File
@@ -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`
@@ -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]]
+93
View File
@@ -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