diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79811aa082..a1255247ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,39 +28,6 @@ Please consider the following guidelines when creating a pull request. - We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) - If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request -## Setting Up The ZITADEL API - -If you want to have a one-liner to get you up and running, -or if you want to develop against a ZITADEL API with the latest features, -or even add changes to ZITADEL itself at the same time, -you should develop against your local ZITADEL process. -However, it might be easier to develop against your ZITADEL Cloud instance -if you don't have docker installed -or have limited resources on your local machine. - -### Developing Against Your Local ZITADEL Instance - -```sh -# To have your service user key and environment file written with the correct ownership, export your current users ID. -export ZITADEL_DEV_UID="$(id -u)" - -# Pull images -docker compose --file ./acceptance/docker-compose.yaml pull - -# Run ZITADEL with local notification sink and configure ./apps/login/.env.local -pnpm run-sink -``` - -### Developing Against Your ZITADEL Cloud Instance - -Configure your shell by exporting the following environment variables: - -```sh -export ZITADEL_API_URL= -export ZITADEL_ORG_ID= -export ZITADEL_SERVICE_USER_TOKEN= -``` - ### Setting up local environment ```sh @@ -76,36 +43,94 @@ pnpm dev The application is now available at `http://localhost:3000` -### Adding applications and IDPs +Configure apps/login/.env.local to target the Zitadel instance of your choice. +The login app live-reloads on changes, so you can start developing right away. + + -# OPTIONAL Run OIDC RP -pnpm run-oidcrp +### Quality Assurance -# OPTIONAL Run SAML IDP -pnpm run-samlidp - -# OPTIONAL Run OIDC OP -pnpm run-oidcop +Use `make` commands to test the quality of your code without installing any dependencies besides Docker. +Using `make` commands, you can reproduce and debug the CI pipelines locally. +```sh +# Reproduce the whole CI pipeline in docker +make login-quality +# Show other options with make +make help ``` -### Testing +Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. -To test the quality of your code, make sure +#### Linting and formatting -You can execute the following commands `pnpm test` for a single test run or `pnpm test:watch` in the following directories: +Check the formatting and linting of the code in docker -- apps/login -- packages/zitadel-proto -- packages/zitadel-client -- packages/zitadel-node -- The projects root directory: all tests in the project are executed +```sh +make login-lint +``` -In apps/login, these commands also spin up the application and a ZITADEL gRPC API mock server to run integration tests using [Cypress](https://www.cypress.io/) against them. -If you want to run the integration tests standalone against an environment of your choice, navigate to ./apps/login, [configure your shell as you like](# Developing Against Your ZITADEL Cloud Instance) and run `pnpm test:integration:run` or `pnpm test:integration:open`. -Then you need to lifecycle the mock process using the command `pnpm mock` or the more fine grained commands `pnpm mock:build`, `pnpm mock:build:nocache`, `pnpm mock:run` and `pnpm mock:destroy`. +Check the linting of the code using pnpm -That's it! 🎉 +```sh +pnpm lint +pnpm format +``` + +Fix the linting of your code + +```sh +pnpm lint:fix +pnpm format:fix +``` + +#### Running Unit Tests + +Run the tests in docker + +```sh +make login-test-unit +``` + +Run unit tests with live-reloading + +```sh +pnpm test:unit +``` + +#### Running Integration Tests + +Run the test in docker + +```sh +make login-test-integration +``` + +Open the Cypress test suite to run the integration tests in interactive mode. +First, set up your local test environment. +This runs a mock server in docker and the login application in dev mode with live-reloading enabled. + +```sh +pnpm test:integration:setup +``` + +Now, in another terminal session, open the interactive Cypress integration test suite. + +```sh +pnpm test:integration open +``` + +Show more options with Cypress + +```sh +pnpm test:integration help +``` diff --git a/Makefile b/Makefile index 6e4e2ff8e2..0dff26c3eb 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ export LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG := login-test-acceptance-samlsp:${DOCKER export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCKER_METADATA_OUTPUT_VERSION} export POSTGRES_TAG := postgres:17.0-alpine3.19 export GOLANG_TAG := golang:1.24-alpine -export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164 +export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:v3.3.0 export CORE_MOCK_TAG := core-mock:${DOCKER_METADATA_OUTPUT_VERSION} .PHONY: login-help @@ -95,6 +95,10 @@ login-test-acceptance: login-test-acceptance-build $(LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG) \ $(LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG)" +.PHONY: login-quality +login-quality: login-lint login-test-unit login-test-integration + @: + .PHONY: login-standalone-build login-standalone-build: $(BAKE_CLI_WITH_COMMON_ARGS) login-standalone diff --git a/apps/login-test-acceptance/docker-compose.yaml b/apps/login-test-acceptance/docker-compose.yaml index 7a9f66f717..d71711338f 100644 --- a/apps/login-test-acceptance/docker-compose.yaml +++ b/apps/login-test-acceptance/docker-compose.yaml @@ -2,7 +2,7 @@ services: zitadel: user: "${UID:-1000}:${GID:-1000}" - image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:v3.3.0}" container_name: acceptance-zitadel pull_policy: always command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' @@ -12,7 +12,6 @@ services: # - "traefik.http.middlewares.zitadel.headers.customrequestheaders.Host=localhost" # - "traefik.http.routers.zitadel.middlewares=zitadel@docker" - "traefik.http.services.zitadel-service.loadbalancer.server.scheme=h2c" - - "traefik.http.services.zitadel-service.loadbalancer.passHostHeader=false" ports: - "8080:8080" volumes: diff --git a/apps/login-test-acceptance/playwright.config.ts b/apps/login-test-acceptance/playwright.config.ts index eb11540450..d8961cb26d 100644 --- a/apps/login-test-acceptance/playwright.config.ts +++ b/apps/login-test-acceptance/playwright.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ timeout: 300 * 1000, // 5 minutes globalTimeout: 30 * 60_000, // 30 minutes /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [["line"], ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0" }]], + reporter: [["line"], ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0", outputFolder: "./playwright-report/html" }]], /* 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('/')`. */ @@ -32,7 +32,7 @@ export default defineConfig({ video: "retain-on-failure", ignoreHTTPSErrors: true, }, - outputDir: "test-results", + outputDir: "test-results/results", /* Configure projects for major browsers */ projects: [ diff --git a/apps/login-test-acceptance/tests/email-verify.spec.ts b/apps/login-test-acceptance/tests/email-verify.spec.ts index 1a188cb035..9a672e4767 100644 --- a/apps/login-test-acceptance/tests/email-verify.spec.ts +++ b/apps/login-test-acceptance/tests/email-verify.spec.ts @@ -45,7 +45,8 @@ test("user email not verified, resend, verify", async ({ user, page }) => { await emailVerifyResend(page); const c = await getCodeFromSink(user.getUsername()); // wait for resend of the code - await page.waitForTimeout(2000); await emailVerify(page, c); + await page.waitForTimeout(2000); + await emailVerify(page, c); await loginScreenExpect(page, user.getFullName()); }); diff --git a/apps/login-test-acceptance/tests/sink.ts b/apps/login-test-acceptance/tests/sink.ts index 91fa209fb6..bc3336b358 100644 --- a/apps/login-test-acceptance/tests/sink.ts +++ b/apps/login-test-acceptance/tests/sink.ts @@ -1,39 +1,39 @@ -import {Gaxios, GaxiosResponse} from 'gaxios'; +import { Gaxios, GaxiosResponse } from "gaxios"; const awaitNotification = new Gaxios({ - url: process.env.SINK_NOTIFICATION_URL, - method: 'POST', - retryConfig: { - httpMethodsToRetry: ['POST'], - statusCodesToRetry: [[404, 404]], - retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries - totalTimeout: 10000, // 10 seconds - onRetryAttempt: (error) => { - console.warn(`Retrying request to sink notification service: ${error.message}`); - } - } + url: process.env.SINK_NOTIFICATION_URL, + method: "POST", + retryConfig: { + httpMethodsToRetry: ["POST"], + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying request to sink notification service: ${error.message}`); + }, + }, }); export async function getOtpFromSink(recipient: string): Promise { - return awaitNotification.request({data: {recipient}}).then((response) => { + return awaitNotification.request({ data: { recipient } }).then((response) => { expectSuccess(response); - const otp = response?.data?.args?.otp + const otp = response?.data?.args?.otp; if (!otp) { - throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); + throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); } return otp; - }) + }); } export async function getCodeFromSink(recipient: string): Promise { - return awaitNotification.request({data: {recipient}}).then((response) => { + return awaitNotification.request({ data: { recipient } }).then((response) => { expectSuccess(response); - const code = response?.data?.args?.code + const code = response?.data?.args?.code; if (!code) { throw new Error(`Response does not contain a code property: ${JSON.stringify(response.data, null, 2)}`); } return code; - }) + }); } function expectSuccess(response: GaxiosResponse): void { diff --git a/apps/login-test-acceptance/tests/user.ts b/apps/login-test-acceptance/tests/user.ts index 71fe8e53b9..3b03291408 100644 --- a/apps/login-test-acceptance/tests/user.ts +++ b/apps/login-test-acceptance/tests/user.ts @@ -1,7 +1,6 @@ import { Page } from "@playwright/test"; import { registerWithPasskey } from "./register"; -import {activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser} from "./zitadel"; -import {request} from 'gaxios'; +import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; export interface userProps { email: string; @@ -112,7 +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 eventualNewUser(this.getUserId()); } } @@ -122,7 +121,7 @@ export class PasswordUserWithTOTP extends User { async ensure(page: Page) { await super.ensure(page); this.secret = await addTOTP(this.getUserId()); - await eventualNewUser(this.getUserId()) + await eventualNewUser(this.getUserId()); } public getSecret(): string { diff --git a/apps/login-test-acceptance/tests/zitadel.ts b/apps/login-test-acceptance/tests/zitadel.ts index 8d479f9a6a..3838eb7fe2 100644 --- a/apps/login-test-acceptance/tests/zitadel.ts +++ b/apps/login-test-acceptance/tests/zitadel.ts @@ -3,11 +3,11 @@ 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 {request} from "gaxios"; -dotenv.config({ path: path.resolve(__dirname, "../env/.env") }) +dotenv.config({ path: path.resolve(__dirname, "../env/.env") }); export async function addUser(props: userProps) { const body = { @@ -173,10 +173,10 @@ export function totp(secret: string) { export async function eventualNewUser(id: string) { return request({ url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, - method: 'GET', + method: "GET", headers: { Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, retryConfig: { statusCodesToRetry: [[404, 404]], @@ -184,7 +184,7 @@ export async function eventualNewUser(id: string) { totalTimeout: 10000, // 10 seconds onRetryAttempt: (error) => { console.warn(`Retrying to query new user ${id}: ${error.message}`); - } - } - }) + }, + }, + }); } diff --git a/apps/login-test-acceptance/zitadel.yaml b/apps/login-test-acceptance/zitadel.yaml index bb64cc028b..ecef8d8334 100644 --- a/apps/login-test-acceptance/zitadel.yaml +++ b/apps/login-test-acceptance/zitadel.yaml @@ -46,6 +46,15 @@ DefaultInstance: HelpLink: "https://zitadel.com/docs" SupportEmail: "support@zitadel.com" DocsLink: "https://zitadel.com/docs" + Features: + LoginV2: + Required: true + +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +SAML: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" Database: EventPushConnRatio: 0.2 # 4 diff --git a/apps/core-mock/Dockerfile b/apps/login-test-integration/core-mock/Dockerfile similarity index 100% rename from apps/core-mock/Dockerfile rename to apps/login-test-integration/core-mock/Dockerfile diff --git a/apps/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json similarity index 100% rename from apps/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json rename to apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json diff --git a/apps/core-mock/mocked-services.cfg b/apps/login-test-integration/core-mock/mocked-services.cfg similarity index 100% rename from apps/core-mock/mocked-services.cfg rename to apps/login-test-integration/core-mock/mocked-services.cfg diff --git a/apps/login-test-integration/integration/invite.cy.ts b/apps/login-test-integration/integration/invite.cy.ts index 4a093c39e3..a68ff96c36 100644 --- a/apps/login-test-integration/integration/invite.cy.ts +++ b/apps/login-test-integration/integration/invite.cy.ts @@ -93,7 +93,7 @@ describe("verify invite", () => { stub("zitadel.user.v2.UserService", "VerifyInviteCode"); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); - cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl +"/authenticator/set"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); }); it("shows an error if invite code validation failed", () => { diff --git a/apps/login-test-integration/integration/login.cy.ts b/apps/login-test-integration/integration/login.cy.ts index a869b3c5eb..917d719cb1 100644 --- a/apps/login-test-integration/integration/login.cy.ts +++ b/apps/login-test-integration/integration/login.cy.ts @@ -95,7 +95,7 @@ describe("login", () => { }); it("should redirect a user with password authentication to /password", () => { cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); - cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl +"/password"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); }); describe("with passkey prompt", () => { beforeEach(() => { @@ -166,7 +166,7 @@ describe("login", () => { it("should redirect a user with passwordless authentication to /passkey", () => { cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); - cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl +"/passkey"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); }); }); }); diff --git a/apps/login-test-integration/integration/register.cy.ts b/apps/login-test-integration/integration/register.cy.ts index 93fc623b53..44c53647c1 100644 --- a/apps/login-test-integration/integration/register.cy.ts +++ b/apps/login-test-integration/integration/register.cy.ts @@ -68,6 +68,6 @@ describe("register", () => { cy.get('input[type="checkbox"][value="privacypolicy"]').check(); cy.get('input[type="checkbox"][value="tos"]').check(); cy.get('button[type="submit"]').click(); - cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl +"/passkey/set"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); }); }); diff --git a/docker-bake.hcl b/docker-bake.hcl index 0a2ada5725..7489b86efb 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -54,7 +54,7 @@ variable "CORE_MOCK_TAG" { } target "core-mock" { - context = "apps/core-mock" + context = "apps/login-test-integration/core-mock" contexts = { protos = "target:proto-files" } diff --git a/dockerfiles/login-test-integration.Dockerfile.dockerignore b/dockerfiles/login-test-integration.Dockerfile.dockerignore index 448ad60ea6..947a4fdb57 100644 --- a/dockerfiles/login-test-integration.Dockerfile.dockerignore +++ b/dockerfiles/login-test-integration.Dockerfile.dockerignore @@ -1,6 +1,7 @@ * !/apps/login-test-integration +/apps/login-test-integration/core-mock **/*.md **/*.png