contributing

This commit is contained in:
Elio Bischof
2025-06-23 13:21:29 +02:00
parent 2a0fd5f9ac
commit 4c701abe4b
17 changed files with 134 additions and 96 deletions

View File

@@ -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=<your cloud instance URL here>
export ZITADEL_ORG_ID=<your service accounts organization id here>
export ZITADEL_SERVICE_USER_TOKEN=<your service account personal access token here>
```
### 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.
<!-- Console doesn't load
### Developing Against Your Local ZITADEL Instance
The following command uses Docker to run a local ZITADEL instance and the login application in live-reloading dev mode.
Additionally, it runs a Traefik reverse proxy that exposes the login at https://localhost with a self-signed certificate.
```sh
# OPTIONAL Run SAML SP
pnpm run-samlsp
pnpm test:acceptance:setup
```
-->
# 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
```

View File

@@ -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

View File

@@ -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:

View File

@@ -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: [

View File

@@ -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());
});

View File

@@ -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<any> {
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<any> {
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 {

View File

@@ -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 {

View File

@@ -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}`);
}
}
})
},
},
});
}

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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");
});
});
});

View File

@@ -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");
});
});

View File

@@ -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"
}

View File

@@ -1,6 +1,7 @@
*
!/apps/login-test-integration
/apps/login-test-integration/core-mock
**/*.md
**/*.png