diff --git a/.devcontainer/base/Dockerfile b/.devcontainer/base/Dockerfile index 8b5a83cbcc..0b26761986 100644 --- a/.devcontainer/base/Dockerfile +++ b/.devcontainer/base/Dockerfile @@ -4,7 +4,6 @@ ENV SHELL=/bin/bash \ DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ - CI=1 \ PNPM_HOME=/home/node/.local/share/pnpm \ PATH=/home/node/.local/share/pnpm:$PATH @@ -17,4 +16,4 @@ RUN apt-get update && \ COPY --chown=node:node commands /commands -USER node +USER node \ No newline at end of file diff --git a/.devcontainer/base/commands/login-acceptance.post-attach.sh b/.devcontainer/base/commands/login-acceptance.post-attach.sh new file mode 100755 index 0000000000..6c1e9157cb --- /dev/null +++ b/.devcontainer/base/commands/login-acceptance.post-attach.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then + set -e +fi + +echo +echo +echo +echo -e "THANKS FOR CONTRIBUTING TO ZITADEL 🚀" +echo +echo "Your dev container is configured for fixing login acceptance tests." +echo "The login is running in a separate container with the same configuration." +echo "It calls a local zitadel container with a fully implemented gRPC API." +echo +echo "Also the test suite is configured correctly." +echo "For example, run a single test file:" +echo "pnpm playwright test --spec acceptance/tests/admin.spec.ts" +echo +echo "You can also run the test interactively." +echo "However, this is only possible from outside the dev container." +echo "On your host machine, run:" +echo "cd apps/login" +echo "pnpm playwright open" +echo "Also consider using the VSCode extension for Playwright:" +echo "https://playwright.dev/docs/getting-started-vscode" +echo +echo "If you want to change the login code, you can replace the login container by a hot reloading dev server." +echo "docker stop login-acceptance" +echo "pnpm turbo dev" +echo "Navigate to the page you want to fix, for example:" +echo "http://localhost:3000/ui/v2/login/loginname" +echo "Change some code and reload the page for instant feedback." +echo +echo "When you are done, make sure all acceptance tests pass:" +echo "pnpm playwright test" +echo + +if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then + exit 0 +fi diff --git a/.devcontainer/base/commands/login-acceptance.update-content.sh b/.devcontainer/base/commands/login-acceptance.update-content.sh new file mode 100755 index 0000000000..afad77f5ef --- /dev/null +++ b/.devcontainer/base/commands/login-acceptance.update-content.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then + echo "Running in fail-on-errors mode" + set -e +fi + +pnpm install --frozen-lockfile \ + --filter @zitadel/login \ + --filter @zitadel/client \ + --filter @zitadel/proto \ + --filter zitadel-monorepo +pnpm exec playwright install --with-deps +pnpm test:acceptance:login + +if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then + exit 0 +fi diff --git a/.devcontainer/base/docker-compose.yaml b/.devcontainer/base/docker-compose.yaml index 2aee9b2572..1fba6d30d7 100644 --- a/.devcontainer/base/docker-compose.yaml +++ b/.devcontainer/base/docker-compose.yaml @@ -30,169 +30,5 @@ services: ports: - "5432:5432" - zitadel: - container_name: zitadel - image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:v4.0.0-rc.2}" - command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' - volumes: - - ../../apps/login/acceptance/pat:/pat:delegated - - ../../apps/login/acceptance/zitadel.yaml:/zitadel.yaml:cached - network_mode: service:devcontainer - healthcheck: - test: - - CMD - - /app/zitadel - - ready - - --config - - /zitadel.yaml - depends_on: - db: - condition: "service_healthy" - - configure-login: - container_name: configure-login - restart: no - build: - context: ../../apps/login/acceptance/setup - dockerfile: ../go-command.Dockerfile - entrypoint: "./setup.sh" - network_mode: service:devcontainer - environment: - PAT_FILE: /pat/zitadel-admin-sa.pat - ZITADEL_API_URL: http://localhost:8080 - WRITE_ENVIRONMENT_FILE: /login-env/.env.test.local - SINK_EMAIL_INTERNAL_URL: http://sink:3333/email - SINK_SMS_INTERNAL_URL: http://sink:3333/sms - SINK_NOTIFICATION_URL: http://sink:3333/notification - LOGIN_BASE_URL: http://localhost:3000/ui/v2/login/ - ZITADEL_API_DOMAIN: localhost - ZITADEL_ADMIN_USER: zitadel-admin@zitadel.localhost - volumes: - - ../../apps/login/acceptance/pat:/pat:cached # Read the PAT file from zitadels setup - - ../../apps/login:/login-env:delegated # Write the environment variables file for the login - depends_on: - zitadel: - condition: "service_healthy" - - login-acceptance: - container_name: login - image: "${LOGIN_TAG:-ghcr.io/zitadel/zitadel-login:v4.0.0-rc.2}" - network_mode: service:devcontainer - volumes: - - ../../apps/login/.env.test.local:/env-files/.env:cached - depends_on: - configure-login: - condition: service_completed_successfully - - mock-notifications: - container_name: mock-notifications - build: - context: ../../apps/login/acceptance/sink - dockerfile: ../go-command.Dockerfile - args: - - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - environment: - PORT: '3333' - command: - - -port - - '3333' - - -email - - '/email' - - -sms - - '/sms' - - -notification - - '/notification' - ports: - - "3333:3333" - depends_on: - configure-login: - condition: "service_completed_successfully" - - mock-oidcrp: - container_name: mock-oidcrp - build: - context: ../../apps/login/acceptance/oidcrp - dockerfile: ../go-command.Dockerfile - args: - - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - network_mode: service:devcontainer - environment: - API_URL: 'http://localhost:8080' - API_DOMAIN: 'localhost' - PAT_FILE: '/pat/zitadel-admin-sa.pat' - LOGIN_URL: 'http://localhost:3000/ui/v2/login' - ISSUER: 'http://localhost:8000' - HOST: 'localhost' - PORT: '8000' - SCOPES: 'openid profile email' - volumes: - - ../../apps/login/acceptance/pat:/pat:cached - depends_on: - configure-login: - condition: "service_completed_successfully" - - # mock-oidcop: - # container_name: mock-oidcop - # build: - # context: ../../apps/login/acceptance/idp/oidc - # dockerfile: ../../go-command.Dockerfile - # args: - # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - # network_mode: service:devcontainer - # environment: - # API_URL: 'http://localhost:8080' - # API_DOMAIN: 'localhost' - # PAT_FILE: '/pat/zitadel-admin-sa.pat' - # SCHEMA: 'http' - # HOST: 'localhost' - # PORT: "8004" - # volumes: - # - "../apps/login/packages/acceptance/pat:/pat:cached" - # depends_on: - # configure-login: - # condition: "service_completed_successfully" - - mock-samlsp: - container_name: mock-samlsp - build: - context: ../../apps/login/acceptance/samlsp - dockerfile: ../go-command.Dockerfile - args: - - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - network_mode: service:devcontainer - environment: - API_URL: 'http://localhost:8080' - API_DOMAIN: 'localhost' - PAT_FILE: '/pat/zitadel-admin-sa.pat' - LOGIN_URL: 'http://localhost:3000/ui/v2/login' - IDP_URL: 'http://localhost:8080/saml/v2/metadata' - HOST: 'http://localhost:8001' - PORT: '8001' - volumes: - - "../apps/login/packages/acceptance/pat:/pat:cached" - depends_on: - configure-login: - condition: "service_completed_successfully" - # mock-samlidp: - # container_name: mock-samlidp - # build: - # context: ../../apps/login/acceptance/idp/saml - # dockerfile: ../../go-command.Dockerfile - # args: - # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - # network_mode: service:devcontainer - # environment: - # API_URL: 'http://localhost:8080' - # API_DOMAIN: 'localhost' - # PAT_FILE: '/pat/zitadel-admin-sa.pat' - # SCHEMA: 'http' - # HOST: 'localhost' - # PORT: "8003" - # volumes: - # - "../apps/login/packages/acceptance/pat:/pat" - # depends_on: - # configure-login: - # condition: "service_completed_successfully" - volumes: postgres-data: diff --git a/.devcontainer/login-acceptance/devcontainer.json b/.devcontainer/login-acceptance/devcontainer.json new file mode 100644 index 0000000000..3ff632677e --- /dev/null +++ b/.devcontainer/login-acceptance/devcontainer.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json", + "name": "Login Acceptance", + "dockerComposeFile": [ + "./docker-compose.yaml" + ], + "service": "login-acceptance-dev", + "runServices": [ + "login-acceptance" + ], + "workspaceFolder": "/workspaces/apps/login", + "forwardPorts": [ + 3000, // Login Dev + 8080, // Zitadel API Dev + 9323, // Playwright Report + ], + "remoteEnv": { + "FAIL_COMMANDS_ON_ERRORS": "${localEnv:FAIL_COMMANDS_ON_ERRORS}", + "DISPLAY": "", + "CI": "${localEnv:CI}" + }, + "updateContentCommand": "/commands/login-acceptance.update-content.sh", + "postAttachCommand": "/commands/login-acceptance.post-attach.sh", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker": {} + } +} diff --git a/.devcontainer/login-acceptance/docker-compose.yaml b/.devcontainer/login-acceptance/docker-compose.yaml new file mode 100644 index 0000000000..7e795a2201 --- /dev/null +++ b/.devcontainer/login-acceptance/docker-compose.yaml @@ -0,0 +1,176 @@ +services: + login-acceptance-dev: + extends: + file: ../base/docker-compose.yaml + service: devcontainer + container_name: login-acceptance-dev + environment: + # Test Suite Configuration + ZITADEL_ADMIN_TOKEN_FILE: /workspaces/apps/login/acceptance/pat/zitadel-admin-sa.pat + SINK_NOTIFICATION_URL: "http://mock-notifications:3333/notification" + # Login Configuration + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + ZITADEL_API_URL: http://localhost:8080 + EMAIL_VERIFICATION: true + ZITADEL_SERVICE_USER_TOKEN_FILE: /workspaces/apps/login/acceptance/pat/login-client-sa.pat + # Zitadel Configuration + ZITADEL_DATABASE_POSTGRES_HOST: db-acceptance + network_mode: service:zitadel + depends_on: + login-acceptance: + condition: service_healthy + mock-notifications: + condition: service_healthy + + db-acceptance: + container_name: db-acceptance + extends: + file: ../base/docker-compose.yaml + service: db + volumes: !reset + + zitadel: + container_name: zitadel + image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:v4.0.0-rc.2}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' + volumes: + - ../../apps/login/acceptance/pat:/pat:delegated + - ../../apps/login/acceptance/zitadel.yaml:/zitadel.yaml:cached + environment: + ZITADEL_DATABASE_POSTGRES_HOST: db-acceptance + healthcheck: + test: + - CMD + - /app/zitadel + - ready + - --config + - /zitadel.yaml + depends_on: + db-acceptance: + condition: "service_healthy" + ports: + - "8080:8080" + - "3000:3000" + + login-acceptance: + container_name: login + image: "${LOGIN_TAG:-ghcr.io/zitadel/zitadel-login:v4.0.0-rc.2}" + network_mode: service:zitadel + environment: + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + ZITADEL_API_URL: http://localhost:8080 + ZITADEL_SERVICE_USER_TOKEN_FILE: /pat/login-client-sa.pat + EMAIL_VERIFICATION: true + volumes: + - ../../apps/login/acceptance/pat:/pat:cached + depends_on: + zitadel: + condition: service_healthy + + mock-notifications: + container_name: mock-notifications + build: + context: ../../apps/login/acceptance/sink + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + PORT: '3333' + volumes: + - ../../apps/login/acceptance/pat:/pat:cached + command: + - -configure-zitadel + - -mock-service-url=http://mock-notifications:3333 + - -zitadel-api-token-file=/pat/zitadel-admin-sa.pat + - -zitadel-api-url=http://zitadel:8080 + ports: + - "3333:3333" + depends_on: + zitadel: + condition: "service_healthy" + + mock-oidcrp: + container_name: mock-oidcrp + build: + context: ../../apps/login/acceptance/oidcrp + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + network_mode: service:zitadel + environment: + API_URL: 'http://localhost:8080' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'http://localhost:3000/ui/v2/login' + ISSUER: 'http://localhost:8000' + HOST: 'localhost' + PORT: '8000' + SCOPES: 'openid profile email' + volumes: + - ../../apps/login/acceptance/pat:/pat:cached + depends_on: + login-acceptance: + condition: "service_healthy" + + # mock-oidcop: + # container_name: mock-oidcop + # build: + # context: ../../apps/login/acceptance/idp/oidc + # dockerfile: ../../go-command.Dockerfile + # args: + # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + # network_mode: service:devcontainer + # environment: + # API_URL: 'http://localhost:8080' + # API_DOMAIN: 'localhost' + # PAT_FILE: '/pat/zitadel-admin-sa.pat' + # SCHEMA: 'http' + # HOST: 'localhost' + # PORT: "8004" + # volumes: + # - "../apps/login/packages/acceptance/pat:/pat:cached" + # depends_on: + # configure-login: + # condition: "service_completed_successfully" + + mock-samlsp: + container_name: mock-samlsp + build: + context: ../../apps/login/acceptance/samlsp + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + network_mode: service:zitadel + environment: + API_URL: 'http://localhost:8080' + API_DOMAIN: 'localhost' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'http://localhost:3000/ui/v2/login' + IDP_URL: 'http://localhost:8080/saml/v2/metadata' + HOST: 'http://localhost:8001' + PORT: '8001' + volumes: + - "../../apps/login/packages/acceptance/pat:/pat:cached" + depends_on: + login-acceptance: + condition: "service_healthy" + # mock-samlidp: + # container_name: mock-samlidp + # build: + # context: ../../apps/login/acceptance/idp/saml + # dockerfile: ../../go-command.Dockerfile + # args: + # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + # network_mode: service:devcontainer + # environment: + # API_URL: 'http://localhost:8080' + # API_DOMAIN: 'localhost' + # PAT_FILE: '/pat/zitadel-admin-sa.pat' + # SCHEMA: 'http' + # HOST: 'localhost' + # PORT: "8003" + # volumes: + # - "../../apps/login/packages/acceptance/pat:/pat" + # depends_on: + # configure-login: + # condition: "service_completed_successfully" + diff --git a/.devcontainer/login-integration/devcontainer.json b/.devcontainer/login-integration/devcontainer.json index 27f9b26003..fb0e43888c 100644 --- a/.devcontainer/login-integration/devcontainer.json +++ b/.devcontainer/login-integration/devcontainer.json @@ -10,13 +10,12 @@ ], "workspaceFolder": "/workspaces/apps/login", "forwardPorts": [ - 22220, - 22222, - 3001 + 3001 // Login Dev ], "remoteEnv": { "FAIL_COMMANDS_ON_ERRORS": "${localEnv:FAIL_COMMANDS_ON_ERRORS}", - "DISPLAY": "" + "DISPLAY": "", + "CI": "${localEnv:CI}" }, "updateContentCommand": "/commands/login-integration.update-content.sh", "postAttachCommand": "/commands/login-integration.post-attach.sh", diff --git a/apps/login/acceptance/sink/main.go b/apps/login/acceptance/sink/main.go index f3795ba0d0..deb596d54d 100644 --- a/apps/login/acceptance/sink/main.go +++ b/apps/login/acceptance/sink/main.go @@ -1,11 +1,14 @@ package main import ( + "bytes" "encoding/json" "flag" "fmt" "io" "net/http" + "os" + "strings" ) type serializableData struct { @@ -24,6 +27,11 @@ func main() { sms := flag.String("sms", "/sms", "path for a sent sms") smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") notification := flag.String("notification", "/notification", "path to receive the notification") + configureZitadel := flag.Bool("configure-zitadel", false, "if set, the sink will configure the Zitadel instance with the given email and sms paths") + zitadelAPIUrl := flag.String("zitadel-api-url", "http://localhost:8080", "Zitadel API URL to configure the sink") + zitadelExternalDomain := flag.String("zitadel-external-domain", "localhost", "Zitadel external domain to configure the sink") + zitadelAPITokenFile := flag.String("zitadel-api-token-file", "", "File containing the Zitadel API token to configure the sink") + mockServiceURL := flag.String("mock-service-url", "http://localhost:3333", "URL of the mock service to be used in tests") flag.Parse() messages := make(map[string]serializableData) @@ -47,7 +55,7 @@ func main() { } fmt.Println(email + ": " + string(data)) messages[email] = serializableData - io.WriteString(w, "Email!\n") + w.Write([]byte("Email!\n")) }) http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { @@ -69,7 +77,7 @@ func main() { } fmt.Println(phone + ": " + string(data)) messages[phone] = serializableData - io.WriteString(w, "SMS!\n") + w.Write([]byte("SMS!\n")) }) http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { @@ -95,17 +103,76 @@ func main() { return } w.Header().Set("Content-Type", "application/json") - io.WriteString(w, string(serializableData)) + w.Write(serializableData) }) + if *configureZitadel { + zitadelAPIToken, err := os.ReadFile(*zitadelAPITokenFile) + if err != nil { + panic("Could not read Zitadel API token file: " + err.Error()) + } + cleanToken := strings.TrimSpace(string(zitadelAPIToken)) + ensureProvider(*zitadelAPIUrl, cleanToken, *zitadelExternalDomain, *mockServiceURL, *email) + ensureProvider(*zitadelAPIUrl, cleanToken, *zitadelExternalDomain, *mockServiceURL, *sms) + } fmt.Println("Starting server on", *port) fmt.Println(*email, " for email handling") fmt.Println(*sms, " for sms handling") fmt.Println(*notification, " for retrieving notifications") - http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) fmt.Println("/healthy returns 200 OK") err := http.ListenAndServe(":"+*port, nil) if err != nil { panic("Server could not be started: " + err.Error()) } } + +func ensureProvider(zitadelAPIUrl string, zitadelAPIToken string, zitadelAPIExternalDomain string, mockServiceUrl string, path string) { + fmt.Println("Ensuring Zitadel provider for", path) + ensureProviderURL := fmt.Sprintf("%s/admin/v1%s/http", zitadelAPIUrl, path) + payload := "{\"endpoint\": \"" + mockServiceUrl + path + "\", \"description\": \"test\"}" + newProvider := &struct { + ID string `json:"id"` + }{} + header := map[string]string{ + "Authorization": "Bearer " + zitadelAPIToken, + } + post(ensureProviderURL, zitadelAPIExternalDomain, header, payload, newProvider) + activateProviderURL := fmt.Sprintf("%s/admin/v1%s/%s/_activate", zitadelAPIUrl, path, newProvider.ID) + post(activateProviderURL, zitadelAPIExternalDomain, header, payload, nil) +} + +func post(url string, host string, header map[string]string, payload string, parseResponse any) { + fmt.Println("POSTing to", url) + req, err := http.NewRequest("POST", url, bytes.NewBufferString(payload)) + if err != nil { + panic("Could not create request: " + err.Error()) + } + req.Host = host + for key, value := range header { + req.Header[key] = []string{value} + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + panic("Could not configure Zitadel: " + err.Error()) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + errorResp, err := io.ReadAll(resp.Body) + if err != nil { + panic("Could not read error response from Zitadel: " + err.Error()) + } + panic(fmt.Sprintf("Zitadel configuration failed with status %d: %s, request url: %s, request headers: %+v", resp.StatusCode, string(errorResp), req.URL, req.Header)) + } + if parseResponse == nil { + return + } + response, err := io.ReadAll(resp.Body) + if err != nil { + panic("Could not read response from Zitadel: " + err.Error()) + } + if err := json.Unmarshal(response, parseResponse); err != nil { + panic("Could not parse response from Zitadel: " + err.Error()) + } + fmt.Println("Zitadel response:", string(response)) +} diff --git a/apps/login/acceptance/tests/admin.spec.ts b/apps/login/acceptance/tests/admin.spec.ts index 13b748fc63..aefd898edb 100644 --- a/apps/login/acceptance/tests/admin.spec.ts +++ b/apps/login/acceptance/tests/admin.spec.ts @@ -1,7 +1,14 @@ -import { test } from "@playwright/test"; +import { test as base } from "@playwright/test"; import { loginScreenExpect, loginWithPassword } from "./login"; +import { Config, ConfigReader } from "./config"; -test("admin login", async ({ page }) => { - await loginWithPassword(page, process.env["ZITADEL_ADMIN_USER"], "Password1!"); +const test = base.extend<{ cfg: Config }>({ + cfg: async ({}, use) => { + await use(new ConfigReader().config); + }, +}); + +test("admin login", async ({ page, cfg }) => { + await loginWithPassword(page, cfg.zitadelAdminUser , "Password1!"); await loginScreenExpect(page, "ZITADEL Admin"); }); diff --git a/apps/login/acceptance/tests/code.ts b/apps/login/acceptance/tests/code.ts index e27d1f6150..55537e3660 100644 --- a/apps/login/acceptance/tests/code.ts +++ b/apps/login/acceptance/tests/code.ts @@ -1,9 +1,10 @@ import { Page } from "@playwright/test"; import { codeScreen } from "./code-screen"; import { getOtpFromSink } from "./sink"; +import { Config } from "./config"; -export async function otpFromSink(page: Page, key: string) { - const c = await getOtpFromSink(key); +export async function otpFromSink(page: Page, key: string, cfg: Config) { + const c = await getOtpFromSink(cfg, key); await code(page, c); } diff --git a/apps/login/acceptance/tests/config.ts b/apps/login/acceptance/tests/config.ts new file mode 100644 index 0000000000..f2932b35ab --- /dev/null +++ b/apps/login/acceptance/tests/config.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; + +export interface Config { + adminToken: string; + zitadelApiUrl: string; + zitadelAdminUser: string; + sinkNotificationUrl: string; +} + +export class ConfigReader { + private readonly _config: Config = { + adminToken: "", + zitadelApiUrl: "", + zitadelAdminUser: "", + sinkNotificationUrl: "", + }; + + constructor() { + this.load(); + } + + private load() { + this._config.adminToken = process.env.ZITADEL_ADMIN_TOKEN || ""; + this._config.zitadelApiUrl = process.env.ZITADEL_API_URL || "http://localhost:8080"; + this._config.zitadelAdminUser = process.env.ZITADEL_ADMIN_USER || "zitadel-admin@zitadel.localhost"; + this._config.sinkNotificationUrl = process.env.SINK_NOTIFICATION_URL || "http://localhost:3333/notification"; + + if (!this._config.adminToken) { + const file = process.env.ZITADEL_ADMIN_TOKEN_FILE; + if (!file) { + throw new Error("ZITADEL_ADMIN_TOKEN_FILE is not set in the environment variables."); + } + const filePath = path.resolve(file); + if (!fs.existsSync(filePath)) { + throw new Error(`ZITADEL_ADMIN_TOKEN_FILE not found at path: ${filePath}`); + } + const token = fs.readFileSync(filePath, "utf-8").trim(); + if (!token) { + throw new Error("ZITADEL_ADMIN_TOKEN_FILE is empty."); + } + this._config.adminToken = token; + } + } + + get config(): Config { + return this._config; + } +} diff --git a/apps/login/acceptance/tests/email-verify.spec.ts b/apps/login/acceptance/tests/email-verify.spec.ts index 2c546b8eee..09d4dfedad 100644 --- a/apps/login/acceptance/tests/email-verify.spec.ts +++ b/apps/login/acceptance/tests/email-verify.spec.ts @@ -1,17 +1,13 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { emailVerify, emailVerifyResend } from "./email-verify"; import { emailVerifyScreenExpect } from "./email-verify-screen"; import { loginScreenExpect, loginWithPassword } from "./login"; import { getCodeFromSink } from "./sink"; import { PasswordUser } from "./user"; +import { Config, ConfigReader } from "./config"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - -const test = base.extend<{ user: PasswordUser }>({ +const test = base.extend<{ user: PasswordUser; cfg: Config }>({ user: async ({ page }, use) => { const user = new PasswordUser({ email: faker.internet.email(), @@ -28,36 +24,41 @@ const test = base.extend<{ user: PasswordUser }>({ await use(user); await user.cleanup(); }, + cfg: async ({}, use) => { + await use(new ConfigReader().config); + } }); -test("user email not verified, verify", async ({ user, page }) => { +test("user email not verified, verify", async ({ user, page, cfg }) => { await loginWithPassword(page, user.getUsername(), user.getPassword()); - const c = await getCodeFromSink(user.getUsername()); + const c = await getCodeFromSink(cfg, user.getUsername()); await emailVerify(page, c); // wait for resend of the code await page.waitForTimeout(2000); await loginScreenExpect(page, user.getFullName()); }); -test("user email not verified, resend, verify", async ({ user, page }) => { +test("user email not verified, resend, verify", async ({ user, page, cfg }) => { await loginWithPassword(page, user.getUsername(), user.getPassword()); + // await for the first code + const first = await getCodeFromSink(cfg, user.getUsername()); // auto-redirect on /verify await emailVerifyResend(page); - const c = await getCodeFromSink(user.getUsername()); - // wait for resend of the code - await page.waitForTimeout(2000); - await emailVerify(page, c); + const second = await getCodeFromSink(cfg, user.getUsername()); + if (first === second) { + throw new Error("Resent code is the same as the first one, expected a different code."); + } + await emailVerify(page, second); await loginScreenExpect(page, user.getFullName()); }); -test("user email not verified, resend, old code", async ({ user, page }) => { +test("user email not verified, resend, old code", async ({ user, page, cfg }) => { await loginWithPassword(page, user.getUsername(), user.getPassword()); - const c = await getCodeFromSink(user.getUsername()); + const first = await getCodeFromSink(cfg, user.getUsername()); await emailVerifyResend(page); - // wait for resend of the code - await page.waitForTimeout(2000); - await emailVerify(page, c); - await emailVerifyScreenExpect(page, c); + const second = await getCodeFromSink(cfg, user.getUsername()); + await emailVerify(page, first); + await emailVerifyScreenExpect(page, first); }); test("user email not verified, wrong code", async ({ user, page }) => { diff --git a/apps/login/acceptance/tests/login.ts b/apps/login/acceptance/tests/login.ts index 2076412456..c7154f99e0 100644 --- a/apps/login/acceptance/tests/login.ts +++ b/apps/login/acceptance/tests/login.ts @@ -3,6 +3,7 @@ import { code, otpFromSink } from "./code"; import { loginname } from "./loginname"; import { password } from "./password"; import { totp } from "./zitadel"; +import { Config } from "./config"; export async function startLogin(page: Page) { await page.goto(`./loginname`); @@ -25,14 +26,14 @@ export async function loginScreenExpect(page: Page, fullName: string) { await expect(page.getByRole("heading")).toContainText(fullName); } -export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { +export async function loginWithPasswordAndEmailOTP(cfg: Config, page: Page, username: string, password: string, email: string) { await loginWithPassword(page, username, password); - await otpFromSink(page, email); + await otpFromSink(page, email, cfg); } -export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { +export async function loginWithPasswordAndPhoneOTP(cfg: Config, page: Page, username: string, password: string, phone: string) { await loginWithPassword(page, username, password); - await otpFromSink(page, phone); + await otpFromSink(page, phone, cfg); } export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { diff --git a/apps/login/acceptance/tests/register.spec.ts b/apps/login/acceptance/tests/register.spec.ts index 4ad7e9e349..f1116ce472 100644 --- a/apps/login/acceptance/tests/register.spec.ts +++ b/apps/login/acceptance/tests/register.spec.ts @@ -1,39 +1,41 @@ import { faker } from "@faker-js/faker"; -import { test } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; +import { test as base } from "@playwright/test"; import { loginScreenExpect } from "./login"; import { registerWithPasskey, registerWithPassword } from "./register"; import { removeUserByUsername } from "./zitadel"; +import { Config, ConfigReader } from "./config"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); +const test = base.extend<{ cfg: Config }>({ + cfg: async ({ page }, use) => { + await use(new ConfigReader().config); + }, +}); -test("register with password", async ({ page }) => { +test("register with password", async ({ page, cfg }) => { const username = faker.internet.email(); const password = "Password1!"; const firstname = faker.person.firstName(); const lastname = faker.person.lastName(); - await registerWithPassword(page, firstname, lastname, username, password, password); + await registerWithPassword(cfg, page, firstname, lastname, username, password, password); await loginScreenExpect(page, firstname + " " + lastname); // wait for projection of user await page.waitForTimeout(10000); - await removeUserByUsername(username); + await removeUserByUsername(username, cfg); }); -test("register with passkey", async ({ page }) => { +test("register with passkey", async ({ page, cfg }) => { const username = faker.internet.email(); const firstname = faker.person.firstName(); const lastname = faker.person.lastName(); - await registerWithPasskey(page, firstname, lastname, username); + await registerWithPasskey(cfg, page, firstname, lastname, username); await loginScreenExpect(page, firstname + " " + lastname); // wait for projection of user await page.waitForTimeout(10000); - await removeUserByUsername(username); + await removeUserByUsername(username, cfg); }); test("register with username and password - only password enabled", async ({ page }) => { diff --git a/apps/login/acceptance/tests/register.ts b/apps/login/acceptance/tests/register.ts index 164a72753b..51501990f6 100644 --- a/apps/login/acceptance/tests/register.ts +++ b/apps/login/acceptance/tests/register.ts @@ -3,8 +3,10 @@ import { emailVerify } from "./email-verify"; import { passkeyRegister } from "./passkey"; import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; import { getCodeFromSink } from "./sink"; +import { Config } from "./config"; export async function registerWithPassword( + cfg: Config, page: Page, firstname: string, lastname: string, @@ -17,10 +19,10 @@ export async function registerWithPassword( await page.getByTestId("submit-button").click(); await registerPasswordScreen(page, password1, password2); await page.getByTestId("submit-button").click(); - await verifyEmail(page, email); + await verifyEmail(cfg, page, email); } -export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { +export async function registerWithPasskey(cfg: Config, page: Page, firstname: string, lastname: string, email: string): Promise { await page.goto("./register"); await registerUserScreenPasskey(page, firstname, lastname, email); await page.getByTestId("submit-button").click(); @@ -29,11 +31,11 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam await page.waitForTimeout(10000); const authId = await passkeyRegister(page); - await verifyEmail(page, email); + await verifyEmail(cfg, page, email); return authId; } -async function verifyEmail(page: Page, email: string) { - const c = await getCodeFromSink(email); +async function verifyEmail(cfg: Config, page: Page, email: string) { + const c = await getCodeFromSink(cfg, email); await emailVerify(page, c); } diff --git a/apps/login/acceptance/tests/sink.ts b/apps/login/acceptance/tests/sink.ts index bc3336b358..dc34541a30 100644 --- a/apps/login/acceptance/tests/sink.ts +++ b/apps/login/acceptance/tests/sink.ts @@ -1,7 +1,8 @@ import { Gaxios, GaxiosResponse } from "gaxios"; +import { Config } from "./config"; -const awaitNotification = new Gaxios({ - url: process.env.SINK_NOTIFICATION_URL, +const awaitNotification = (cfg: Config) => new Gaxios({ + url: cfg.sinkNotificationUrl, method: "POST", retryConfig: { httpMethodsToRetry: ["POST"], @@ -14,8 +15,8 @@ const awaitNotification = new Gaxios({ }, }); -export async function getOtpFromSink(recipient: string): Promise { - return awaitNotification.request({ data: { recipient } }).then((response) => { +export async function getOtpFromSink(cfg: Config, recipient: string): Promise { + return awaitNotification(cfg).request({ data: { recipient } }).then((response) => { expectSuccess(response); const otp = response?.data?.args?.otp; if (!otp) { @@ -25,8 +26,8 @@ export async function getOtpFromSink(recipient: string): Promise { }); } -export async function getCodeFromSink(recipient: string): Promise { - return awaitNotification.request({ data: { recipient } }).then((response) => { +export async function getCodeFromSink(cfg: Config, recipient: string): Promise { + return awaitNotification(cfg).request({ data: { recipient } }).then((response) => { expectSuccess(response); const code = response?.data?.args?.code; if (!code) { diff --git a/apps/login/acceptance/tests/user.ts b/apps/login/acceptance/tests/user.ts index 3b03291408..cec4db6188 100644 --- a/apps/login/acceptance/tests/user.ts +++ b/apps/login/acceptance/tests/user.ts @@ -1,6 +1,7 @@ import { Page } from "@playwright/test"; import { registerWithPasskey } from "./register"; import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; +import { ConfigReader } from './config' export interface userProps { email: string; @@ -14,22 +15,23 @@ export interface userProps { isPhoneVerified?: boolean; } -class User { +class User extends ConfigReader { private readonly props: userProps; private user: string; constructor(userProps: userProps) { + super(); this.props = userProps; } async ensure(page: Page) { - const response = await addUser(this.props); - + const response = await addUser(this.props, this.config); this.setUserId(response.userId); + eventualNewUser(this.getUserId(), this.config); } async cleanup() { - await removeUser(this.getUserId()); + await removeUser(this.getUserId(), this.config); } public setUserId(userId: string) { @@ -68,7 +70,6 @@ class User { export class PasswordUser extends User { async ensure(page: Page) { await super.ensure(page); - await eventualNewUser(this.getUserId()); } } @@ -110,8 +111,7 @@ export class PasswordUserWithOTP extends User { async ensure(page: Page) { await super.ensure(page); - await activateOTP(this.getUserId(), this.type); - await eventualNewUser(this.getUserId()); + await activateOTP(this.getUserId(), this.type, this.config); } } @@ -120,8 +120,7 @@ export class PasswordUserWithTOTP extends User { async ensure(page: Page) { await super.ensure(page); - this.secret = await addTOTP(this.getUserId()); - await eventualNewUser(this.getUserId()); + this.secret = await addTOTP(this.getUserId(), this.config); } public getSecret(): string { @@ -156,7 +155,7 @@ export class PasskeyUser extends User { } public async ensure(page: Page) { - const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + const authId = await registerWithPasskey(this.config, page, this.getFirstname(), this.getLastname(), this.getUsername()); this.authenticatorId = authId; // wait for projection of user @@ -164,11 +163,11 @@ export class PasskeyUser extends User { } async cleanup() { - const resp: any = await getUserByUsername(this.getUsername()); + const resp: any = await getUserByUsername(this.getUsername(), this.config); if (!resp || !resp.result || !resp.result[0]) { return; } - await removeUser(resp.result[0].userId); + await removeUser(resp.result[0].userId, this.config); } public getAuthenticatorId(): string { diff --git a/apps/login/acceptance/tests/username-passkey.spec.ts b/apps/login/acceptance/tests/username-passkey.spec.ts index dff1c65f5a..5f83fb2c68 100644 --- a/apps/login/acceptance/tests/username-passkey.spec.ts +++ b/apps/login/acceptance/tests/username-passkey.spec.ts @@ -1,14 +1,9 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { loginScreenExpect, loginWithPasskey } from "./login"; import { PasskeyUser } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - -const test = base.extend<{ user: PasskeyUser }>({ +const test = base.extend<{ user: PasskeyUser; }>({ user: async ({ page }, use) => { const user = new PasskeyUser({ email: faker.internet.email(), diff --git a/apps/login/acceptance/tests/username-password-change-required.spec.ts b/apps/login/acceptance/tests/username-password-change-required.spec.ts index 50605e5ff0..04d11bc3d9 100644 --- a/apps/login/acceptance/tests/username-password-change-required.spec.ts +++ b/apps/login/acceptance/tests/username-password-change-required.spec.ts @@ -1,14 +1,9 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { loginScreenExpect, loginWithPassword } from "./login"; import { changePassword } from "./password"; import { PasswordUser } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ diff --git a/apps/login/acceptance/tests/username-password-changed.spec.ts b/apps/login/acceptance/tests/username-password-changed.spec.ts index dc29dc2286..e7cf2cdf82 100644 --- a/apps/login/acceptance/tests/username-password-changed.spec.ts +++ b/apps/login/acceptance/tests/username-password-changed.spec.ts @@ -1,15 +1,10 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { loginScreenExpect, loginWithPassword } from "./login"; import { changePassword, startChangePassword } from "./password"; import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; import { PasswordUser } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ diff --git a/apps/login/acceptance/tests/username-password-otp_email.spec.ts b/apps/login/acceptance/tests/username-password-otp_email.spec.ts index e4a77751c1..889e9e603c 100644 --- a/apps/login/acceptance/tests/username-password-otp_email.spec.ts +++ b/apps/login/acceptance/tests/username-password-otp_email.spec.ts @@ -1,16 +1,12 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { code, codeResend, otpFromSink } from "./code"; import { codeScreenExpect } from "./code-screen"; import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; import { OtpType, PasswordUserWithOTP } from "./user"; +import { Config, ConfigReader } from "./config"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - -const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ +const test = base.extend<{ user: PasswordUserWithOTP; cfg: Config }>({ user: async ({ page }, use) => { const user = new PasswordUserWithOTP({ email: faker.internet.email(), @@ -29,9 +25,12 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ await use(user); await user.cleanup(); }, + cfg: async ({}, use) => { + await use(new ConfigReader().config); + }, }); -test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ user, page }) => { +test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ cfg, user, page }) => { // Given email otp is enabled on the organization of the user // Given the user has only email otp configured as second factor // User enters username @@ -39,7 +38,7 @@ test.skip("DOESN'T WORK: username, password and email otp login, enter code manu // User receives an email with a verification code // User enters the code into the ui // User is redirected to the app (default redirect url) - await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginWithPasswordAndEmailOTP(cfg, page, user.getUsername(), user.getPassword(), user.getUsername()); await loginScreenExpect(page, user.getFullName()); }); @@ -54,7 +53,7 @@ test("username, password and email otp login, click link in email", async ({ pag // User is redirected to the app (default redirect url) }); -test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ user, page }) => { +test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ cfg, user, page }) => { // Given email otp is enabled on the organization of the user // Given the user has only email otp configured as second factor // User enters username @@ -66,7 +65,7 @@ test.skip("DOESN'T WORK: username, password and email otp login, resend code", a // User is redirected to the app (default redirect url) await loginWithPassword(page, user.getUsername(), user.getPassword()); await codeResend(page); - await otpFromSink(page, user.getUsername()); + await otpFromSink(page, user.getUsername(), cfg); await loginScreenExpect(page, user.getFullName()); }); diff --git a/apps/login/acceptance/tests/username-password-otp_sms.spec.ts b/apps/login/acceptance/tests/username-password-otp_sms.spec.ts index 10901cd243..7fc14a341e 100644 --- a/apps/login/acceptance/tests/username-password-otp_sms.spec.ts +++ b/apps/login/acceptance/tests/username-password-otp_sms.spec.ts @@ -1,15 +1,10 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { code } from "./code"; import { codeScreenExpect } from "./code-screen"; import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; import { OtpType, PasswordUserWithOTP } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ user: async ({ page }, use) => { const user = new PasswordUserWithOTP({ diff --git a/apps/login/acceptance/tests/username-password-set.spec.ts b/apps/login/acceptance/tests/username-password-set.spec.ts index 06ce42f1a7..d784f3dc8d 100644 --- a/apps/login/acceptance/tests/username-password-set.spec.ts +++ b/apps/login/acceptance/tests/username-password-set.spec.ts @@ -1,16 +1,11 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; import { loginname } from "./loginname"; import { resetPassword, startResetPassword } from "./password"; import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; import { PasswordUser } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ diff --git a/apps/login/acceptance/tests/username-password-totp.spec.ts b/apps/login/acceptance/tests/username-password-totp.spec.ts index e495b16681..4285acd7ae 100644 --- a/apps/login/acceptance/tests/username-password-totp.spec.ts +++ b/apps/login/acceptance/tests/username-password-totp.spec.ts @@ -1,15 +1,10 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { code } from "./code"; import { codeScreenExpect } from "./code-screen"; import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; import { PasswordUserWithTOTP } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ user: async ({ page }, use) => { const user = new PasswordUserWithTOTP({ diff --git a/apps/login/acceptance/tests/username-password.spec.ts b/apps/login/acceptance/tests/username-password.spec.ts index ceb340f8da..af206964bd 100644 --- a/apps/login/acceptance/tests/username-password.spec.ts +++ b/apps/login/acceptance/tests/username-password.spec.ts @@ -1,7 +1,5 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; import { loginname } from "./loginname"; import { loginnameScreenExpect } from "./loginname-screen"; @@ -9,10 +7,7 @@ import { password } from "./password"; import { passwordScreenExpect } from "./password-screen"; import { PasswordUser } from "./user"; -// Read from ".env" file. -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - -const test = base.extend<{ user: PasswordUser }>({ +const test = base.extend<{ user: PasswordUser, adminToken: string }>({ user: async ({ page }, use) => { const user = new PasswordUser({ email: faker.internet.email(), diff --git a/apps/login/acceptance/tests/zitadel.ts b/apps/login/acceptance/tests/zitadel.ts index b252654f86..de4d0ab67e 100644 --- a/apps/login/acceptance/tests/zitadel.ts +++ b/apps/login/acceptance/tests/zitadel.ts @@ -2,14 +2,11 @@ import { Authenticator } from "@otplib/core"; import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin import axios from "axios"; -import dotenv from "dotenv"; import { request } from "gaxios"; -import path from "path"; import { OtpType, userProps } from "./user"; +import { Config } from "./config"; -dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); - -export async function addUser(props: userProps) { +export async function addUser(props: userProps, cfg: Config) { const body = { username: props.email, organization: { @@ -21,44 +18,37 @@ export async function addUser(props: userProps) { }, email: { email: props.email, - isVerified: true, + isVerified: props.isEmailVerified || undefined, }, phone: { phone: props.phone, - isVerified: true, + isVerified: props.isPhoneVerified || undefined, }, password: { password: props.password, changeRequired: props.passwordChangeRequired ?? false, }, }; - if (!props.isEmailVerified) { - delete body.email.isVerified; - } - if (!props.isPhoneVerified) { - delete body.phone.isVerified; - } - - return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); + return await listCall(`${cfg.zitadelApiUrl}/v2/users/human`, body, cfg); } -export async function removeUserByUsername(username: string) { - const resp = await getUserByUsername(username); +export async function removeUserByUsername(username: string, cfg: Config) { + const resp = await getUserByUsername(username, cfg); if (!resp || !resp.result || !resp.result[0]) { return; } - await removeUser(resp.result[0].userId); + await removeUser(resp.result[0].userId, cfg); } -export async function removeUser(id: string) { - await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +export async function removeUser(id: string, cfg: Config) { + await deleteCall(`${cfg.zitadelApiUrl}/v2/users/${id}`, cfg); } -async function deleteCall(url: string) { +async function deleteCall(url: string, cfg: Config) { try { const response = await axios.delete(url, { headers: { - Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + Authorization: `Bearer ${cfg.adminToken}`, }, }); @@ -73,7 +63,7 @@ async function deleteCall(url: string) { } } -export async function getUserByUsername(username: string): Promise { +export async function getUserByUsername(username: string, cfg: Config): Promise { const listUsersBody = { queries: [ { @@ -84,15 +74,15 @@ export async function getUserByUsername(username: string): Promise { ], }; - return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); + return await listCall(`${cfg.zitadelApiUrl}/v2/users`, listUsersBody, cfg); } -async function listCall(url: string, data: any): Promise { +async function listCall(url: string, data: any, cfg: Config): Promise { try { const response = await axios.post(url, data, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + Authorization: `Bearer ${cfg.adminToken}`, }, }); @@ -109,7 +99,7 @@ async function listCall(url: string, data: any): Promise { } } -export async function activateOTP(userId: string, type: OtpType) { +export async function activateOTP(userId: string, type: OtpType, cfg: Config) { let url = "otp_"; switch (type) { case OtpType.sms: @@ -120,15 +110,15 @@ export async function activateOTP(userId: string, type: OtpType) { break; } - await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); + await pushCall(`${cfg.zitadelApiUrl}/v2/users/${userId}/${url}`, {}, cfg); } -async function pushCall(url: string, data: any) { +async function pushCall(url: string, data: any, cfg: Config) { try { const response = await axios.post(url, data, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + Authorization: `Bearer ${cfg.adminToken}`, }, }); @@ -143,10 +133,10 @@ async function pushCall(url: string, data: any) { } } -export async function addTOTP(userId: string): Promise { - const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); +export async function addTOTP(userId: string, cfg: Config): Promise { + const response = await listCall(`${cfg.zitadelApiUrl}/v2/users/${userId}/totp`, {}, cfg); const code = totp(response.secret); - await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + await pushCall(`${cfg.zitadelApiUrl}/v2/users/${userId}/totp/verify`, { code: code }, cfg); return response.secret; } @@ -170,12 +160,12 @@ export function totp(secret: string) { return token; } -export async function eventualNewUser(id: string) { +export async function eventualNewUser(id: string, cfg: Config) { return request({ - url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, + url: `${cfg.zitadelApiUrl}/v2/users/${id}`, method: "GET", headers: { - Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + Authorization: `Bearer ${cfg.adminToken}`, "Content-Type": "application/json", }, retryConfig: { diff --git a/apps/login/acceptance/zitadel.yaml b/apps/login/acceptance/zitadel.yaml index 986574fed2..c9b07df033 100644 --- a/apps/login/acceptance/zitadel.yaml +++ b/apps/login/acceptance/zitadel.yaml @@ -3,6 +3,7 @@ TLS.Enabled: false FirstInstance: PatPath: /pat/zitadel-admin-sa.pat + LoginClientPatPath: /pat/login-client-sa.pat Org: Human: UserName: zitadel-admin @@ -60,6 +61,3 @@ Database: MaxConnLifetime: 1h MaxConnIdleTime: 5m User.Password: zitadel - -Logstore.Access.Stdout.Enabled: true -Log.Formatter.Format: json \ No newline at end of file diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index b84f11a230..5e1a8e7351 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -69,7 +69,7 @@ const nextConfig = { }, eslint: { ignoreDuringBuilds: true, - }, + }, async headers() { return [ { diff --git a/apps/login/package.json b/apps/login/package.json index 84c9ce5907..2254e43b28 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -16,9 +16,7 @@ "lint-staged": "lint-staged", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", "test:integration:login": "wait-on --simultaneous 1 http://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc && cypress run", - "test:acceptance": "dotenv -e ../login/.env.test.local playwright", - "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev", - "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + "test:acceptance:login": "wait-on --simultaneous 1 http://localhost:3000/ui/v2/login/loginname && playwright test" }, "git": { "pre-commit": "lint-staged" @@ -71,6 +69,7 @@ "@vercel/git-hooks": "1.0.0", "@vitejs/plugin-react": "^4.4.1", "autoprefixer": "10.4.21", + "axios": "^1.11.0", "concurrently": "^9.1.2", "cypress": "^14.5.2", "dotenv-cli": "^8.0.0", diff --git a/apps/login/playwright-report/.gitignore b/apps/login/playwright-report/.gitignore new file mode 100644 index 0000000000..bf27f3114d --- /dev/null +++ b/apps/login/playwright-report/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/apps/login/playwright-report/.gitkeep b/apps/login/playwright-report/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/login/acceptance/playwright.config.ts b/apps/login/playwright.config.ts similarity index 84% rename from apps/login/acceptance/playwright.config.ts rename to apps/login/playwright.config.ts index 8025db3238..3dbe1b924b 100644 --- a/apps/login/acceptance/playwright.config.ts +++ b/apps/login/playwright.config.ts @@ -1,25 +1,22 @@ import { defineConfig, devices } from "@playwright/test"; -import dotenv from "dotenv"; -import path from "path"; - -dotenv.config({ path: path.resolve(__dirname, "../login/.env.test.local") }); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: "./tests", + testDir: './acceptance/tests', + testMatch: '*.spec.ts', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - expect: { - timeout: 10_000, // 10 seconds - }, - timeout: 300 * 1000, // 5 minutes - globalTimeout: 30 * 60_000, // 30 minutes +// expect: { +// timeout: 10_000, // 10 seconds +// }, +// timeout: 300 * 1000, // 5 minutes +// globalTimeout: 30 * 60_000, // 30 minutes /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ["line"], @@ -28,7 +25,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: process.env.LOGIN_BASE_URL || "http://127.0.0.1:3000", + baseURL: process.env.LOGIN_BASE_URL || "http://localhost:3000/ui/v2/login/", trace: "retain-on-failure", headless: true, screenshot: "only-on-failure", diff --git a/apps/login/test-results/.last-run.json b/apps/login/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/apps/login/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/apps/login/test-results/results/.last-run.json b/apps/login/test-results/results/.last-run.json new file mode 100644 index 0000000000..a5f629d971 --- /dev/null +++ b/apps/login/test-results/results/.last-run.json @@ -0,0 +1,10 @@ +{ + "status": "failed", + "failedTests": [ + "0a9b39404336a6e58147-0089074729cd5bdd63bd", + "0a9b39404336a6e58147-17df57115074bb19a1e9", + "fe80ef673e57408fdf11-7ed53ec9af8a1d4af5f8", + "224d262b73cc3e01411e-956ffcd06f4566a831e6", + "224d262b73cc3e01411e-82cd99ba1a2749241ea2" + ] +} \ No newline at end of file diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/error-context.md b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/error-context.md new file mode 100644 index 0000000000..098720cb67 --- /dev/null +++ b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/error-context.md @@ -0,0 +1,20 @@ +# Page snapshot + +```yaml +- heading "Verify user" [level=1] +- paragraph: Enter the Code provided in the verification email. +- text: A code has just been sent to your email address. DM Eulah.Connelly-Barrows67@yahoo.com +- link: + - /url: /ui/v2/login/accounts?organization=331901194370875395&loginName=Eulah.Connelly-Barrows67%40yahoo.com +- text: Didn't receive a code? +- button "Resend Code": Resend code +- text: Code +- textbox "Code": HS3BYV +- text: Could not verify email +- button "Back" +- button "Continue" +- button "English" +- button +- button +- alert: Verify user +``` \ No newline at end of file diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/test-failed-1.png b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/test-failed-1.png new file mode 100644 index 0000000000..4ab7ca4c1e Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/test-failed-1.png differ diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/trace.zip b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/trace.zip new file mode 100644 index 0000000000..1a71b47a02 Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/trace.zip differ diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/video.webm b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/video.webm new file mode 100644 index 0000000000..1d55721201 Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-resend-verify-chromium/video.webm differ diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/error-context.md b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/error-context.md new file mode 100644 index 0000000000..c7dc436d0a --- /dev/null +++ b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/error-context.md @@ -0,0 +1,20 @@ +# Page snapshot + +```yaml +- heading "Verify user" [level=1] +- paragraph: Enter the Code provided in the verification email. +- text: A code has just been sent to your email address. HJ Curt.Bernhard-Tromp@yahoo.com +- link: + - /url: /ui/v2/login/accounts?organization=331901194370875395&loginName=Curt.Bernhard-Tromp%40yahoo.com +- text: Didn't receive a code? +- button "Resend Code": Resend code +- text: Code +- textbox "Code": NUVDFB +- text: Could not verify email +- button "Back" +- button "Continue" +- button "English" +- button +- button +- alert: Verify user +``` \ No newline at end of file diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/test-failed-1.png b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/test-failed-1.png new file mode 100644 index 0000000000..e10121e5ec Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/test-failed-1.png differ diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/trace.zip b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/trace.zip new file mode 100644 index 0000000000..6d5c5acccb Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/trace.zip differ diff --git a/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/video.webm b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/video.webm new file mode 100644 index 0000000000..b21c9505fd Binary files /dev/null and b/apps/login/test-results/results/email-verify-user-email-not-verified-verify-chromium/video.webm differ diff --git a/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/error-context.md b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/error-context.md new file mode 100644 index 0000000000..faf9ac7417 --- /dev/null +++ b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/error-context.md @@ -0,0 +1,13 @@ +# Page snapshot + +```yaml +- heading "Welcome Marianne Mayer!" [level=1] +- paragraph: You are signed in. +- text: MM Thora.Smith78@hotmail.com +- link: + - /url: /ui/v2/login/accounts?organization=331901194370875395&loginName=Thora.Smith78%40hotmail.com +- button "English" +- button +- button +- alert: Welcome Marianne Mayer! +``` \ No newline at end of file diff --git a/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/test-failed-1.png b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/test-failed-1.png new file mode 100644 index 0000000000..178e309eb9 Binary files /dev/null and b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/test-failed-1.png differ diff --git a/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/trace.zip b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/trace.zip new file mode 100644 index 0000000000..36ecf41e49 Binary files /dev/null and b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/trace.zip differ diff --git a/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/video.webm b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/video.webm new file mode 100644 index 0000000000..e366bd0ba7 Binary files /dev/null and b/apps/login/test-results/results/username-password-changed--08e44--and-password-changed-login-chromium/video.webm differ diff --git a/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/error-context.md b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/error-context.md new file mode 100644 index 0000000000..76c2235c8e --- /dev/null +++ b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/error-context.md @@ -0,0 +1,35 @@ +# Page snapshot + +```yaml +- heading "Oliver Stamm" [level=1] +- paragraph: Set the password for your account +- text: OS Stanton25@gmail.com +- link: + - /url: /ui/v2/login/accounts?organization=331901194370875395&loginName=Stanton25%40gmail.com +- text: A code has been sent to your email address. Didn't receive a code? +- button "Resend OTP Code": Resend code +- text: Code * +- textbox "Code *" +- text: New Password * +- textbox "New Password *" +- text: Confirm Password * +- textbox "Confirm Password *" +- img "Doesn't match" +- text: Password length 8 +- img "Doesn't match" +- text: has Symbol +- img "Doesn't match" +- text: has Number +- img "Doesn't match" +- text: has uppercase +- img "Doesn't match" +- text: has lowercase +- img "Doesn't match" +- text: equals +- button "Back" +- button "Continue" [disabled] +- button "English" +- button +- button +- alert: Oliver Stamm +``` \ No newline at end of file diff --git a/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/test-failed-1.png b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/test-failed-1.png new file mode 100644 index 0000000000..22231ec71d Binary files /dev/null and b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/test-failed-1.png differ diff --git a/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/trace.zip b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/trace.zip new file mode 100644 index 0000000000..2ac272a50e Binary files /dev/null and b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/trace.zip differ diff --git a/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/video.webm b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/video.webm new file mode 100644 index 0000000000..5036f4d38e Binary files /dev/null and b/apps/login/test-results/results/username-password-set-pass-ca947-not-with-desired-complexity-chromium/video.webm differ diff --git a/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/error-context.md b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/error-context.md new file mode 100644 index 0000000000..267399d553 --- /dev/null +++ b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/error-context.md @@ -0,0 +1,35 @@ +# Page snapshot + +```yaml +- heading "Chet Torp-Kihn" [level=1] +- paragraph: Set the password for your account +- text: CT Jena20@yahoo.com +- link: + - /url: /ui/v2/login/accounts?organization=331901194370875395&loginName=Jena20%40yahoo.com +- text: A code has been sent to your email address. Didn't receive a code? +- button "Resend OTP Code": Resend code +- text: Code * +- textbox "Code *" +- text: New Password * +- textbox "New Password *" +- text: Confirm Password * +- textbox "Confirm Password *" +- img "Doesn't match" +- text: Password length 8 +- img "Doesn't match" +- text: has Symbol +- img "Doesn't match" +- text: has Number +- img "Doesn't match" +- text: has uppercase +- img "Doesn't match" +- text: has lowercase +- img "Doesn't match" +- text: equals +- button "Back" +- button "Continue" [disabled] +- button "English" +- button +- button +- alert: Chet Torp-Kihn +``` \ No newline at end of file diff --git a/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/test-failed-1.png b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/test-failed-1.png new file mode 100644 index 0000000000..dad6e504f4 Binary files /dev/null and b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/test-failed-1.png differ diff --git a/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/trace.zip b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/trace.zip new file mode 100644 index 0000000000..88c77e550e Binary files /dev/null and b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/trace.zip differ diff --git a/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/video.webm b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/video.webm new file mode 100644 index 0000000000..e7405b9e7d Binary files /dev/null and b/apps/login/test-results/results/username-password-set-username-and-password-set-login-chromium/video.webm differ diff --git a/package.json b/package.json index e04aa6f535..e58164be09 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "changeset": "changeset", "devcontainer:lint-unit": "FAIL_COMMANDS_ON_ERRORS=true devcontainer up --prebuild --config .devcontainer/turbo-lint-unit/devcontainer.json --workspace-folder .", "devcontainer:integration:login": "FAIL_COMMANDS_ON_ERRORS=true devcontainer up --prebuild --config .devcontainer/login-integration/devcontainer.json --workspace-folder .", + "devcontainer:acceptance:login": "FAIL_COMMANDS_ON_ERRORS=true devcontainer up --prebuild --config .devcontainer/login-acceptance/devcontainer.json --workspace-folder .", "clean": "turbo run clean", "clean:all": "pnpm run clean && rm -rf .turbo node_modules" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe94a017d..0fa7be649e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: autoprefixer: specifier: 10.4.21 version: 10.4.21(postcss@8.5.3) + axios: + specifier: ^1.11.0 + version: 1.11.0(debug@4.4.1) concurrently: specifier: ^9.1.2 version: 9.2.0