chore: rehaul DevX (#10571)

# Which Problems Are Solved

Replaces Turbo by Nx and lays the foundation for the next CI
improvements. It enables using Nx Cloud to speed the up the pipelines
that affect any node package.
It streamlines the dev experience for frontend and backend developers by
providing the following commands:

| Task | Command | Notes |
|------|---------|--------|
| **Production** | `nx run PROJECT:prod` | Production server |
| **Develop** | `nx run PROJECT:dev` | Hot reloading development server
|
| **Test** | `nx run PROJECT:test` | Run all tests |
| **Lint** | `nx run PROJECT:lint` | Check code style |
| **Lint Fix** | `nx run PROJECT:lint-fix` | Auto-fix style issues |

The following values can be used for PROJECT:

- @zitadel/zitadel (root commands)
- @zitadel/api,
- @zitadel/login,
- @zitadel/console,
- @zitadel/docs,
- @zitadel/client
- @zitadel/proto

The project names and folders are streamlined:

| Old Folder | New Folder |
| --- | --- |
| ./e2e | ./tests/functional-ui |
| ./load-test | ./benchmark |
| ./build/zitadel | ./apps/api |
| ./console | ./apps/console (postponed so the PR is reviewable) |  

Also, all references to the TypeScript repo are removed so we can
archive it.

# How the Problems Are Solved

- Ran `npx nx@latest init`
- Replaced all turbo.json by project.json and fixed the target configs
- Removed Turbo dependency
- All JavaScript related code affected by a PRs changes is
quality-checked using the `nx affected` command
- We move PR checks that are runnable using Nx into the `check`
workflow. For workflows where we don't use Nx, yet, we restore
previously built dependency artifacts from Nx.
- We only use a single and easy to understand dev container
- The CONTRIBUTING.md is streamlined
- The setup with a generated client pat is orchestrated with Nx
- Everything related to the TypeScript repo is updated or removed. A
**Deploy with Vercel** button is added to the docs and the
CONTRIBUTING.md.

# Additional Changes

- NPM package names have a consistent pattern.
- Docker bake is removed. The login container is built and released like
the core container.
- The integration tests build the login container before running, so
they don't rely on the login container action anymore. This fixes
consistently failing checks on PRs from forks.
- The docs build in GitHub actions is removed, as we already build on
Vercel.

# Additional Context

- Internal discussion:
https://zitadel.slack.com/archives/C087ADF8LRX/p1756277884928169
- Workflow dispatch test:
https://github.com/zitadel/zitadel/actions/runs/17760122959

---------

Co-authored-by: Florian Forster <[email protected]>
Co-authored-by: Tim Möhlmann <[email protected]>
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Elio Bischof
2025-10-08 10:27:02 +02:00
committed by GitHub
co-authored by Florian Forster Tim Möhlmann Copilot
parent e47acaeef2
commit f69a6ed4f3
236 changed files with 5708 additions and 6549 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"presets": ["@babel/env", "@babel/typescript"],
"plugins": [
"@babel/proposal-class-properties",
"@babel/proposal-object-rest-spread"
]
}
+2
View File
@@ -0,0 +1,2 @@
.env
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 125,
"singleQuote": true,
"trailingComma": "all"
}
+108
View File
@@ -0,0 +1,108 @@
VUS ?= 20
DURATION ?= "200s"
ZITADEL_HOST ?=
ADMIN_LOGIN_NAME ?=
ADMIN_PASSWORD ?=
DATE := $(shell date '+%d-%H:%M:%S')
K6 := ./../../xk6-modules/k6
.PHONY: human_password_login
human_password_login: bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/human_password_login.js --vus ${VUS} --duration ${DURATION} --out csv=output/human_password_login_${DATE}.csv
.PHONY: machine_pat_login
machine_pat_login: bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_pat_login_${DATE}.csv
.PHONY: machine_client_credentials_login
machine_client_credentials_login: bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_client_credentials_login.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_client_credentials_login_${DATE}.csv
.PHONY: user_info
user_info: bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/user_info.js --vus ${VUS} --duration ${DURATION} --out csv=output/user_info_${DATE}.csv
.PHONY: manipulate_user
manipulate_user: bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} --out csv=output/manipulate_user_${DATE}.csv
.PHONY: introspect
introspect: ensure_modules bundle
go install go.k6.io/xk6/cmd/xk6@latest
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/introspection.js --vus ${VUS} --duration ${DURATION} --out csv=output/introspect_${DATE}.csv
.PHONY: oidc_session
oidc_session: ensure_key_pair ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/oidc_session.js --vus ${VUS} --duration ${DURATION} --out csv=output/oidc_session_${DATE}.csv
.PHONY: otp_session
otp_session: ensure_key_pair ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/otp_session.js --vus ${VUS} --duration ${DURATION} --out csv=output/otp_session_${DATE}.csv
.PHONY: password_session
password_session: ensure_key_pair ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/password_session.js --vus ${VUS} --duration ${DURATION} --out csv=output/otp_session_${DATE}.csv
.PHONY: add_session
add_session: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/add_session.js --vus ${VUS} --duration ${DURATION} --out csv=output/add_session_${DATE}.csv
.PHONY: machine_jwt_profile_grant
machine_jwt_profile_grant: ensure_modules ensure_key_pair bundle
go install go.k6.io/xk6/cmd/xk6@latest
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_jwt_profile_grant_${DATE}.csv
.PHONY: machine_jwt_profile_grant_single_user
machine_jwt_profile_grant_single_user: ensure_modules ensure_key_pair bundle
go install go.k6.io/xk6/cmd/xk6@latest
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant_single_user.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_jwt_profile_grant_single_user_${DATE}.csv
.PHONY: verify_all_user_grants_exist
verify_all_user_grants_exist: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/verify_all_user_grants_exist.js --vus ${VUS} --duration ${DURATION}
# --out csv=output/verify_all_user_grants_exist_${DATE}.csv
.PHONY: users_by_metadata_key
users_by_metadata_key: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_key.js --vus ${VUS} --duration ${DURATION} --out csv=output/users_by_metadata_${DATE}.csv
.PHONY: users_by_metadata_value
users_by_metadata_value: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_value.js --vus ${VUS} --duration ${DURATION} --out csv=output/users_by_metadata_${DATE}.csv
.PHONY: lint
lint:
npm i
npm run lint:fix
.PHONY: ensure_modules
ensure_modules:
ifeq (,$(wildcard $(PWD)/../../xk6-modules))
@echo "cloning xk6-modules"
cd ../.. && git clone https://github.com/zitadel/xk6-modules.git
endif
cd ../../xk6-modules && git pull
.PHONY: bundle
bundle:
mkdir -p output
npm i
npm run bundle
go install go.k6.io/xk6/cmd/xk6@latest
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
.PHONY: ensure_key_pair
ensure_key_pair:
ifeq (,$(wildcard $(PWD)/.keys))
mkdir .keys
endif
ifeq (,$(wildcard $(PWD)/.keys/key.pem))
openssl genrsa -out .keys/key.pem 2048
endif
ifeq (,$(wildcard $(PWD)/.keys/key.pem.pub))
openssl rsa -in .keys/key.pem -outform PEM -pubout -out .keys/key.pem.pub
endif
+79
View File
@@ -0,0 +1,79 @@
# Benchmarks
This package contains code for benchmarking specific endpoints of the API using [k6](https://k6.io).
## Prerequisite
* [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
* [k6](https://k6.io/docs/get-started/installation/)
* [go](https://go.dev/doc/install)
* running the API
## Structure
The use cases under tests are defined in `src/use_cases`. The implementation of ZITADEL resources and calls are located under `src`.
## Execution
### Env vars
* `VUS`: Amount of parallel processes execute the test (default is 20)
* `DURATION`: Defines how long the tests are executed (default is `200s`)
* `ZITADEL_HOST`: URL of ZITADEL (default is `http://localhost:8080`)
* `ADMIN_LOGIN_NAME`: Loginanme of a human user with `IAM_OWNER`-role
* `ADMIN_PASSWORD`: password of the human user
To setup the tests we use the credentials of console and log in using an admin. The user must be able to create organizations and all resources inside organizations.
* `ADMIN_LOGIN_NAME`: `[email protected]`
* `ADMIN_PASSWORD`: `Password1!`
### Test
Before you run the tests you need an initialized user. The tests don't implement the change password screen during login.
* `make human_password_login`
setup: creates human users
test: uses the previously created humans to sign in using the login ui
* `make machine_pat_login`
setup: creates machines and a pat for each machine
test: calls user info endpoint with the given pats
* `make machine_client_credentials_login`
setup: creates machines and a client credential secret for each machine
test: calls token endpoint with the `client_credentials` grant type.
* `make user_info`
setup: creates human users and signs them in
test: calls user info endpoint using the given humans
* `make manipulate_user`
test: creates a human, updates its profile, locks the user and then deletes it
* `make introspect`
setup: creates projects, one api per project, one key per api and generates the jwt from the given keys
test: calls introspection endpoint using the given JWTs
* `make add_session`
setup: creates human users
test: creates new sessions with user id check
* `make oidc_session`
setup: creates a machine user to create the auth request and session.
test: creates an auth request, a session and links the session to the auth request. Implementation of [this flow](https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard).
* `make otp_session`
setup: creates 1 human user for each VU and adds email OTP to it
test: creates a session based on the login name of the user, sets the email OTP challenge to the session and afterwards checks the OTP code
* `make password_session`
setup: creates 1 human user for each VU and adds email OTP to it
test: creates a session based on the login name of the user and checks for the password on a second step
* `make machine_jwt_profile_grant`
setup: generates private/public key, creates machine users, adds a key
test: creates a token and calls user info
* `make machine_jwt_profile_grant_single_user`
setup: generates private/public key, creates machine user, adds a key
test: creates a token and calls user info in parallel for the same user
* `make users_by_metadata_key`
setup: creates for half of the VUS a human user and a machine for the other half, adds 3 metadata to each user
test: calls the list users endpoint and filters by a metadata key
* `make users_by_metadata_value`
setup: creates for half of the VUS a human user and a machine for the other half, adds 3 metadata to each user
test: calls the list users endpoint and filters by a metadata value
* `make verify_all_user_grants_exists`
setup: creates 50 projects, 1 machine per VU
test: creates a machine and grants all projects to the machine
teardown: the organization is not removed to verify the data of the projections are correct. You can find additional information [at the bottom of this file](./src/use_cases/verify_all_user_grants_exist.ts)
+4467
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@zitadel/benchmark",
"repository": "ssh://[email protected]/zitadel/zitadel.git",
"author": "ZITADEL Authors <[email protected]>",
"engines": {
"node": "18 || 20"
},
"license": "Apache-2.0",
"devDependencies": {
"@babel/core": "7.23.7",
"@babel/plugin-proposal-class-properties": "7.13.0",
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
"@babel/preset-env": "7.23.8",
"@babel/preset-typescript": "7.23.3",
"@types/k6": ">=1.2.0",
"@types/webpack": "5.28.5",
"babel-loader": "9.1.3",
"clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "^12.0.2",
"prettier": "^3.3.3",
"prettier-plugin-organize-imports": "^3.2.4",
"typescript": "5.4.5",
"webpack": "5.95.0",
"webpack-cli": "5.1.4",
"webpack-glob-entries": "^1.0.1"
},
"scripts": {
"bundle": "webpack",
"lint": "prettier --check src/**",
"lint:fix": "prettier --write src",
"clean": "rm -rf dist node_modules"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
export type API = {
appId: string;
};
const addAPITrend = new Trend('app_add_app_duration', true);
export function createAPI(name: string, projectId: string, org: Org, accessToken: string): Promise<API> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/projects/${projectId}/apps/api`),
JSON.stringify({
name: name,
authMethodType: 'API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT',
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add api status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add api project: ${projectId} status: ${res.status} body: ${res.body}`);
resolve(res.json() as API);
addAPITrend.add(res.timings.duration);
});
});
}
export type AppKey = {
keyDetails: string;
};
const addAppKeyTrend = new Trend('app_add_app_key_duration', true);
export function createAppKey(appId: string, projectId: string, org: Org, accessToken: string): Promise<AppKey> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/projects/${projectId}/apps/${appId}/keys`),
JSON.stringify({
type: 'KEY_TYPE_JSON',
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add app key status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add app key project: ${projectId} app: ${appId} status: ${res.status} body: ${res.body}`);
resolve(res.json() as AppKey);
addAppKeyTrend.add(res.timings.duration);
});
});
}
+67
View File
@@ -0,0 +1,67 @@
// @ts-ignore Import module
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import crypto from 'k6/crypto';
import http from 'k6/http';
import execution from 'k6/execution';
import { Stage } from 'k6/options';
import url from './url';
export const Config = {
host: __ENV.ZITADEL_HOST || 'http://localhost:8080',
orgId: '',
codeVerifier: __ENV.CODE_VERIFIER || randomString(10),
admin: {
loginName: __ENV.ADMIN_LOGIN_NAME || '[email protected]',
password: __ENV.ADMIN_PASSWORD || 'Password1!',
},
};
const client = {
response_type: 'code',
scope: 'openid email profile urn:zitadel:iam:org:project:id:zitadel:aud',
prompt: 'login',
code_challenge_method: 'S256',
code_challenge: crypto.sha256(Config.codeVerifier, 'base64rawurl'),
client_id: __ENV.CLIENT_ID || '',
redirect_uri: url('/ui/console/auth/callback'),
};
export function Client() {
if (client.client_id) {
return client;
}
const env = http.get(url('/ui/console/assets/environment.json'));
client.client_id = env.json('clientid') ? env.json('clientid')?.toString()! : '';
return client;
}
let maxVUs: number;
export function MaxVUs() {
if (maxVUs != undefined) {
return maxVUs;
}
let max: number = execution.test.options.stages
? execution.test.options.stages
.map((value: Stage): number => value.target)
.reduce((acc: number, value: number): number => {
return acc <= value ? acc : value;
})
: 1;
if (execution.test.options.scenarios) {
new Map(Object.entries(execution.test.options.scenarios)).forEach((value) => {
if ('vus' in value) {
max = value.vus && max < value.vus ? value.vus : max;
} else if ('maxVUs' in value) {
max = value.maxVUs && max < value.maxVUs ? value.maxVUs : max;
}
});
}
maxVUs = max;
return maxVUs;
}
+131
View File
@@ -0,0 +1,131 @@
import { JSONObject, check, fail } from 'k6';
import http, { Response } from 'k6/http';
// @ts-ignore Import module
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js';
import { Trend } from 'k6/metrics';
import { Config, Client } from './config';
import url from './url';
import { User } from './user';
import { Tokens } from './oidc';
export function loginByUsernamePassword(user: User) {
check(user, {
'user defined': (u) => u !== undefined || fail(`user is undefined`),
});
const loginUI = initLogin();
const loginNameResponse = enterLoginName(loginUI, user);
const passwordResponse = enterPassword(loginNameResponse, user);
return token(new URL(passwordResponse.url).searchParams.get('code'));
}
const initLoginTrend = new Trend('login_ui_init_login_duration', true);
export function initLogin(clientId?: string): Response {
let params = {};
let expectedStatus = 200;
if (clientId) {
params = {
headers: {
'x-zitadel-login-client': clientId,
},
redirects: 0,
};
expectedStatus = 302;
}
const response = http.get(
url('/oauth/v2/authorize', {
searchParams: Client(),
}),
params,
);
check(response, {
'authorize status ok': (r) => r.status == expectedStatus || fail(`init login failed: ${JSON.stringify(r)}`),
});
initLoginTrend.add(response.timings.duration);
return response;
}
const enterLoginNameTrend = new Trend('login_ui_enter_login_name_duration', true);
function enterLoginName(page: Response, user: User): Response {
const response = page.submitForm({
formSelector: 'form',
fields: {
loginName: user.loginName,
},
});
check(response, {
'login name status ok': (r) => (r && r.status >= 200 && r.status < 300) || fail('enter login name failed'),
'login shows password page': (r) => r && r.body !== null && r.body.toString().includes('password'),
// 'login has no error': (r) => r && r.body != null && r.body.toString().includes('error') || fail(`error in enter login name ${r.body}`)
});
enterLoginNameTrend.add(response.timings.duration);
return response;
}
const enterPasswordTrend = new Trend('login_ui_enter_password_duration', true);
function enterPassword(page: Response, user: User): Response {
let response = page.submitForm({
formSelector: 'form',
fields: {
password: user.password,
},
});
enterPasswordTrend.add(response.timings.duration);
// skip 2fa init
if (response.url.endsWith('/password')) {
response = response.submitForm({
formSelector: 'form',
submitSelector: '[name="skip"]',
});
}
check(response, {
'password status ok': (r) => (r.status >= 200 && r.status < 300) || fail('enter password failed'),
'password callback': (r) =>
r.url.startsWith(url('/ui/console/auth/callback?code=')) || fail(`wrong password callback: ${r.url}`),
});
return response;
}
const tokenTrend = new Trend('login_ui_token_duration', true);
function token(code = '') {
check(code, {
'code set': (c) => (c !== undefined && c !== null) || fail('code was not set'),
});
const response = http.post(
url('/oauth/v2/token'),
{
grant_type: 'authorization_code',
code: code,
redirect_uri: Client().redirect_uri,
code_verifier: Config.codeVerifier,
client_id: Client().client_id,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
tokenTrend.add(response.timings.duration);
check(response, {
'token status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`invalid token response status: ${r.status} body: ${r.body}`),
});
const token = new Tokens(response.json() as JSONObject);
check(token, {
'access token created': (t) => t.accessToken !== undefined,
'id token created': (t) => t.idToken !== undefined,
'info created': (t) => t.info !== undefined,
});
return token;
}
+26
View File
@@ -0,0 +1,26 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { check, fail } from 'k6';
const addIAMMemberTrend = new Trend('membership_iam_member', true);
export async function addIAMMember(userId: string, roles: string[], accessToken: string): Promise<void> {
const res = await http.post(
url('/admin/v1/members'),
JSON.stringify({
userId: userId,
roles: roles,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
},
);
check(res, {
'member added successful': (r) =>
(r.status >= 200 && r.status < 300) || fail(`unable add member: ${JSON.stringify(res)}`),
});
addIAMMemberTrend.add(res.timings.duration);
}
+4
View File
@@ -0,0 +1,4 @@
export type Metadata = {
key: string;
value: string;
};
+212
View File
@@ -0,0 +1,212 @@
import { JSONObject, check, fail } from 'k6';
import encoding from 'k6/encoding';
import http, { RequestBody, Response } from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { Client, Config } from './config';
// @ts-ignore Import module
import zitadel from 'k6/x/zitadel';
export class Tokens {
idToken?: string;
accessToken?: string;
info?: any;
constructor(res: JSONObject) {
this.idToken = res.id_token ? res.id_token!.toString() : undefined;
this.accessToken = res.access_token ? res.access_token!.toString() : undefined;
this.info = this.idToken
? JSON.parse(encoding.b64decode(this.idToken?.split('.')[1].toString(), 'rawstd', 's'))
: undefined;
}
}
let oidcConfig: any | undefined;
function configuration() {
if (oidcConfig !== undefined) {
return oidcConfig;
}
const res = http.get(url('/.well-known/openid-configuration'));
check(res, {
'openid configuration': (r) => (r.status >= 200 && r.status < 300) || fail('unable to load openid configuration'),
});
oidcConfig = res.json();
return oidcConfig;
}
const userinfoTrend = new Trend('oidc_user_info_duration', true);
export function userinfo(token: string) {
const userinfo = http.get(configuration().userinfo_endpoint, {
headers: {
authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
},
});
check(userinfo, {
'userinfo status ok': (r) => r.status >= 200 && r.status < 300,
});
userinfoTrend.add(userinfo.timings.duration);
}
const introspectTrend = new Trend('oidc_introspect_duration', true);
export function introspect(jwt: string, token: string) {
const res = http.post(
configuration().introspection_endpoint,
{
client_assertion: jwt,
token: token,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
alg: 'RS256',
},
},
);
check(res, {
'introspect status ok': (r) => r.status >= 200 && r.status < 300,
});
introspectTrend.add(res.timings.duration);
}
const clientCredentialsTrend = new Trend('oidc_client_credentials_duration', true);
export function clientCredentials(clientId: string, clientSecret: string): Promise<Tokens> {
return new Promise((resolve, reject) => {
const response = http.asyncRequest(
'POST',
configuration().token_endpoint,
{
grant_type: 'client_credentials',
scope: 'openid profile urn:zitadel:iam:org:project:id:zitadel:aud',
client_id: clientId,
client_secret: clientSecret,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
response.then((res) => {
check(res, {
'client credentials status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`client credentials request failed (client id: ${clientId}) status: ${res.status} body: ${res.body}`);
clientCredentialsTrend.add(res.timings.duration);
const tokens = new Tokens(res.json() as JSONObject);
check(tokens, {
'client credentials token ok': (t) => t.accessToken !== undefined,
}) || reject(`client credentials access token missing (client id: ${clientId}`);
resolve(tokens);
});
});
}
export interface TokenRequest {
payload(): RequestBody;
headers(): { [name: string]: string };
}
const privateKey = open('../.keys/key.pem');
export class JWTProfileRequest implements TokenRequest {
keyPayload!: {
userId: string;
expiration: number;
keyId: string;
};
constructor(userId: string, keyId: string) {
this.keyPayload = {
userId: userId,
// 1 minute
expiration: 60 * 1_000_000_000,
keyId: keyId,
};
}
payload(): RequestBody {
const assertion = zitadel.signJWTProfileAssertion(this.keyPayload.userId, this.keyPayload.keyId, {
audience: [Config.host],
expiration: this.keyPayload.expiration,
key: privateKey,
});
return {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
scope: 'openid urn:zitadel:iam:org:project:id:zitadel:aud',
assertion: `${assertion}`,
};
}
public headers(): { [name: string]: string } {
return {
'Content-Type': 'application/x-www-form-urlencoded',
};
}
}
const tokenDurationTrend = new Trend('oidc_token_duration', true);
export async function token(request: TokenRequest): Promise<Tokens> {
return http
.asyncRequest('POST', configuration().token_endpoint, request.payload(), {
headers: request.headers(),
})
.then((res) => {
tokenDurationTrend.add(res.timings.duration);
check(res, {
'token status ok': (r) => r.status >= 200 && r.status < 300,
'access token returned': (r) => r.json('access_token')! != undefined && r.json('access_token')! != '',
});
return new Tokens(res.json() as JSONObject);
});
}
const authRequestByIDTrend = new Trend('oidc_auth_request_by_id_duration', true);
export async function authRequestByID(id: string, tokens: any): Promise<Response> {
const response = http.get(url(`/v2/oidc/auth_requests/${id}`), {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
check(response, {
'authorize status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`auth request by failed: ${JSON.stringify(r)}`),
});
authRequestByIDTrend.add(response.timings.duration);
return response;
}
const finalizeAuthRequestTrend = new Trend('oidc_auth_request_finalize', true);
export async function finalizeAuthRequest(id: string, session: any, tokens: any): Promise<Response> {
const res = await http.post(
url(`/v2/oidc/auth_requests/${id}`),
JSON.stringify({
session: {
sessionId: session.sessionId,
sessionToken: session.sessionToken,
},
}),
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'Content-Type': 'application/json',
// 'Accept': 'application/json',
'x-zitadel-login-client': tokens.info.client_id,
},
},
);
check(res, {
'finalize auth request status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
});
finalizeAuthRequestTrend.add(res.timings.duration);
return res;
}
+56
View File
@@ -0,0 +1,56 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { Config } from './config';
import { check } from 'k6';
export type Org = {
organizationId: string;
};
const createOrgTrend = new Trend('org_create_org_duration', true);
export function createOrg(accessToken: string): Promise<Org> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2/organizations'),
JSON.stringify({
name: `load-test-${new Date(Date.now()).toISOString()}`,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': Config.orgId,
},
},
);
response.then((res) => {
check(res, {
'org created': (r) => {
return r !== undefined && r.status >= 200 && r.status < 300;
},
}) || reject(`unable to create org status: ${res.status} || body: ${res.body}`);
createOrgTrend.add(res.timings.duration);
resolve(res.json() as Org);
});
});
}
export function removeOrg(org: Org, accessToken: string) {
const response = http.del(url('/management/v1/orgs/me'), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'x-zitadel-orgid': org.organizationId,
},
});
check(response, {
'org removed': (r) => r.status >= 200 && r.status < 300,
}) || console.log(`status: ${response.status} || body: ${response.body}|| org: ${JSON.stringify(org)}`);
return response.json();
}
+37
View File
@@ -0,0 +1,37 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
export type Project = {
id: string;
};
const addProjectTrend = new Trend('project_add_project_duration', true);
export function createProject(name: string, org: Org, accessToken: string): Promise<Project> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/management/v1/projects'),
JSON.stringify({
name: name,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add project status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add project status: ${res.status} body: ${res.body}`);
addProjectTrend.add(res.timings.duration);
resolve(res.json() as Project);
});
});
}
+58
View File
@@ -0,0 +1,58 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
export type Session = {
challenges: any;
id: string;
token: string;
};
const addSessionTrend = new Trend('session_add_session_duration', true);
export function createSession(org: Org, accessToken: string, checks?: any): Promise<Session> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url('/v2/sessions'), checks ? JSON.stringify({ checks: checks }) : null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'add Session status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add Session status: ${res.status} body: ${res.body}`);
addSessionTrend.add(res.timings.duration);
resolve(res.json() as Session);
});
});
}
const setSessionTrend = new Trend('session_set_session_duration', true);
export function setSession(id: string, session: any, accessToken: string, challenges?: any, checks?: any): Promise<Session> {
const body = {
sessionToken: session.sessionToken,
checks: checks ? checks : null,
challenges: challenges ? challenges : null,
};
return new Promise((resolve, reject) => {
let response = http.asyncRequest('PATCH', url(`/v2/sessions/${id}`), JSON.stringify(body), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
// 'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'set Session status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to set Session status: ${res.status} body: ${res.body}`);
setSessionTrend.add(res.timings.duration);
resolve(res.json() as Session);
});
});
}
+18
View File
@@ -0,0 +1,18 @@
import { options } from 'k6/http';
import { Config } from './config';
export type options = {
searchParams?: { [name: string]: string };
};
export default function url(path: string, options: options = {}) {
let url = new URL(Config.host + path);
if (options.searchParams) {
Object.entries(options.searchParams).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
return url.toString();
}
@@ -0,0 +1,37 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { User, createHuman } from '../user';
import { userinfo } from '../oidc';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../config';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, users: humans, org };
}
const humanPasswordLoginTrend = new Trend('human_password_login_duration', true);
export default function (data: any) {
const start = new Date();
const token = loginByUsernamePassword(data.users[__VU - 1]);
userinfo(token.accessToken!);
humanPasswordLoginTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}
+54
View File
@@ -0,0 +1,54 @@
import { loginByUsernamePassword } from '../login_ui';
import { createAPI, createAppKey } from '../app';
import { createProject } from '../project';
import { createOrg, removeOrg } from '../org';
import { introspect } from '../oidc';
import { Config, MaxVUs } from '../config';
import { b64decode } from 'k6/encoding';
// @ts-ignore Import module
import zitadel from 'k6/x/zitadel';
import { User } from '../user';
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const projectPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createProject(`project-${i}`, org, adminTokens.accessToken!);
});
const projects = await Promise.all(projectPromises);
console.log(`setup: ${projects.length} projects created`);
const apis = await Promise.all(
projects.map((project, i) => {
return createAPI(`api-${i}`, project.id, org, adminTokens.accessToken!);
}),
);
console.info(`setup: ${apis.length} apis created`);
const keys = await Promise.all(
apis.map((api, i) => {
return createAppKey(api.appId, projects[i].id, org, adminTokens.accessToken!);
}),
);
console.info(`setup: ${keys.length} keys created`);
const tokens = keys.map((key) => {
return zitadel.jwtFromKey(b64decode(key.keyDetails, 'url', 's'), Config.host);
});
console.info(`setup: ${tokens.length} tokens generated`);
return { adminTokens, tokens, org };
}
export default function (data: any) {
introspect(data.tokens[__VU - 1], data.adminTokens.accessToken);
}
export function teardown(data: any) {
removeOrg(data.org, data.adminTokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,48 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createMachine, User, addMachineSecret } from '../user';
import { clientCredentials, userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
let machines = (
await Promise.all(
Array.from({ length: MaxVUs() }, (_, i) => {
return createMachine(`zitachine-${i}`, org, tokens.accessToken!);
}),
)
).map((machine) => {
return { userId: machine.userId, loginName: machine.loginNames[0] };
});
console.info(`setup: ${machines.length} machines created`);
let credentials = (
await Promise.all(
machines.map((machine) => {
return addMachineSecret(machine.userId, org, tokens.accessToken!);
}),
)
).map((credentials, i) => {
return { userId: machines[i].userId, loginName: machines[i].loginName, password: credentials.clientSecret };
});
console.info(`setup: secrets added`);
return { tokens, machines: credentials, org };
}
export default function (data: any) {
clientCredentials(data.machines[__VU - 1].loginName, data.machines[__VU - 1].password).then((token) => {
userinfo(token.accessToken!);
});
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,49 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createMachine, User, addMachineKey } from '../user';
import { JWTProfileRequest, token, userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
import encoding from 'k6/encoding';
const publicKey = encoding.b64encode(open('../.keys/key.pem.pub'));
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
let machines = (
await Promise.all(
Array.from({ length: MaxVUs() }, (_, i) => {
return createMachine(`zitachine-${i}`, org, tokens.accessToken!);
}),
)
).map((machine) => {
return { userId: machine.userId, loginName: machine.loginNames[0] };
});
console.info(`setup: ${machines.length} machines created`);
let keys = (
await Promise.all(
machines.map((machine) => {
return addMachineKey(machine.userId, org, tokens.accessToken!, publicKey);
}),
)
).map((key, i) => {
return { userId: machines[i].userId, keyId: key.keyId };
});
console.info(`setup: ${keys.length} keys added`);
return { tokens, machines: keys, org };
}
export default function (data: any) {
token(new JWTProfileRequest(data.machines[__VU - 1].userId, data.machines[__VU - 1].keyId));
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,32 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createMachine, User, addMachineKey } from '../user';
import { JWTProfileRequest, token, userinfo } from '../oidc';
import { Config } from '../config';
import encoding from 'k6/encoding';
const publicKey = encoding.b64encode(open('../.keys/key.pem.pub'));
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const machine = await createMachine(`zitachine`, org, tokens.accessToken!);
console.info(`setup: machine ${machine.userId} created`);
const key = await addMachineKey(machine.userId, org, tokens.accessToken!, publicKey);
console.info(`setup: key ${key.keyId} added`);
return { tokens, machine: { userId: machine.userId, keyId: key.keyId }, org };
}
export default function (data: any) {
token(new JWTProfileRequest(data.machine.userId, data.machine.keyId));
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,46 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createMachine, addMachinePat, User } from '../user';
import { userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
let machines = (
await Promise.all(
Array.from({ length: MaxVUs() }, (_, i) => {
return createMachine(`zitachine-${i}`, org, tokens.accessToken!);
}),
)
).map((machine) => {
return { userId: machine.userId, loginName: machine.loginNames[0] };
});
console.info(`setup: ${machines.length} machines created`);
let pats = (
await Promise.all(
machines.map((machine) => {
return addMachinePat(machine.userId, org, tokens.accessToken!);
}),
)
).map((pat, i) => {
return { userId: machines[i].userId, loginName: machines[i].loginName, pat: pat.token };
});
console.info(`setup: Pats added`);
return { tokens, machines: pats, org };
}
export default function (data: any) {
userinfo(data.machines[__VU - 1].pat);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,47 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, updateHuman, lockUser, deleteUser, User } from '../user';
import { Config } from '../config';
import { check } from 'k6';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
return { tokens, org };
}
export default async function (data: any) {
const human = await createHuman(`vu-${__VU}-${new Date(Date.now()).getTime()}`, data.org, data.tokens.accessToken);
const updateRes = await updateHuman(
{
profile: {
nickName: `${new Date(Date.now()).toISOString()}`,
},
},
human.userId,
data.org,
data.tokens.accessToken,
);
check(updateRes, {
'update user is status ok': (r) => r.status >= 200 && r.status < 300,
});
const lockRes = await lockUser(human.userId, data.org, data.tokens.accessToken);
check(lockRes, {
'lock user is status ok': (r) => r.status >= 200 && r.status < 300,
});
const deleteRes = await deleteUser(human.userId, data.org, data.tokens.accessToken);
check(deleteRes, {
'delete user is status ok': (r) => r.status >= 200 && r.status < 300,
});
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,45 @@
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { User, createHuman, createMachine } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../../config';
import { createSession } from '../../session';
import { check } from 'k6';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, users: humans, org };
}
const addSessionTrend = new Trend('add_session_duration', true);
export default async function (data: any) {
const start = new Date();
const session = await createSession(data.org, data.tokens.accessToken, {
user: {
userId: data.users[__VU - 1].userId,
},
});
check(session, {
'add session is status ok': (s) => s.id !== '',
});
addSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}
@@ -0,0 +1,53 @@
import { loginByUsernamePassword, initLogin } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { User, addMachineKey, createMachine } from '../../user';
import { Trend } from 'k6/metrics';
import { Config } from '../../config';
import { check } from 'k6';
import { finalizeAuthRequest, JWTProfileRequest, token } from '../../oidc';
import { createSession } from '../../session';
import encoding from 'k6/encoding';
import { addIAMMember } from '../../membership';
const publicKey = encoding.b64encode(open('../.keys/key.pem.pub'));
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const loginUser = await createMachine('load-test', org, adminTokens.accessToken!);
const loginUserKey = await addMachineKey(loginUser.userId, org, adminTokens.accessToken!, publicKey);
await addIAMMember(loginUser.userId, ['IAM_LOGIN_CLIENT'], adminTokens.accessToken!);
const tokens = await token(new JWTProfileRequest(loginUser.userId, loginUserKey.keyId));
return { tokens, user: loginUser, key: loginUserKey, org, adminTokens };
}
// implements the flow described in
// https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard
const addSessionTrend = new Trend('oidc_session_duration', true);
export default async function (data: any) {
const start = new Date();
const authorizeResponse = initLogin(data.tokens.info.client_id);
const authRequestId = new URLSearchParams(authorizeResponse.headers['Location']).values().next().value;
check(authRequestId, {
'auth request id returned': (s) => s !== '',
});
const session = await createSession(data.org, data.tokens.accessToken, {
user: {
userId: data.user.userId,
},
});
await finalizeAuthRequest(authRequestId!, session, data.tokens!);
addSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.adminTokens.accessToken);
}
@@ -0,0 +1,57 @@
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { createHuman, setEmailOTPOnHuman, User } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../../config';
import { createSession, setSession } from '../../session';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
setEmailOTPOnHuman(user, org, tokens.accessToken!);
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, org, users: humans };
}
// implements the flow described in
// https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard
const otpSessionTrend = new Trend('otp_session_duration', true);
export default async function (data: any) {
const start = new Date();
let session = await createSession(data.org, data.tokens.accessToken, {
user: {
loginName: data.users[__VU - 1].loginName,
},
});
const sessionId = (session as any).sessionId;
session = await setSession(sessionId, session, data.tokens.accessToken, {
otpEmail: {
return_code: {},
},
});
session = await setSession(sessionId, session, data.tokens.accessToken, null, {
otpEmail: {
code: session.challenges.otpEmail,
},
});
otpSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}
@@ -0,0 +1,49 @@
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { createHuman, setEmailOTPOnHuman, User } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../../config';
import { createSession, setSession } from '../../session';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
setEmailOTPOnHuman(user, org, tokens.accessToken!);
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, org, users: humans };
}
const passwordSessionTrend = new Trend('password_session_duration', true);
export default async function (data: any) {
const start = new Date();
let session = await createSession(data.org, data.tokens.accessToken, {
user: {
loginName: data.users[__VU - 1].loginName,
},
});
const sessionId = (session as any).sessionId;
session = await setSession(sessionId, session, data.tokens.accessToken, null, {
password: {
password: data.users[__VU - 1].password,
},
});
passwordSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}
+27
View File
@@ -0,0 +1,27 @@
import { loginByUsernamePassword } from '../login_ui';
import { userinfo } from '../oidc';
import { Config } from '../config';
import { User, createHuman } from '../user';
import { createOrg, removeOrg } from '../org';
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const user = await createHuman('gigi', org, adminTokens.accessToken!);
console.info(`setup: user (${user.userId}) created`);
return { org, tokens: loginByUsernamePassword({ loginName: user.loginNames[0], password: 'Password1!' } as User) };
}
export default function (data: any) {
userinfo(data.tokens.accessToken);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,65 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
import { Config } from '../config';
import { check } from 'k6';
import encoding from 'k6/encoding';
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const users: User[] = [];
await Promise.all(
Array.from({ length: userAmount }, async (_, i) => {
let user: User;
let type: 'human' | 'machine';
if (i % 2 === 0) {
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
type = 'human';
} else {
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
type = 'machine';
}
users.push(user);
await setUserMetadata(
[
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
],
user.userId,
tokens.accessToken!,
);
if (i % 10 === 0) {
console.log(`setup: ${i} of ${userAmount} users setup`);
}
}),
);
console.info(`setup: ${users.length} users created`);
return { tokens, org, users };
}
export default async function (data: any) {
const result = await listUsers(
{
queries: [{ metadataKeyFilter: { key: 'org', method: 'TEXT_FILTER_METHOD_EQUALS' } }],
},
data.tokens.accessToken!,
);
check(result, {
'total result length': (res) => res.details.totalResult == userAmount,
}) || console.log(`unexpected amount of users. expected ${userAmount} but got ${result.details.totalResult}`);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,67 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
import { Config } from '../config';
import { check } from 'k6';
import encoding from 'k6/encoding';
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const users: User[] = [];
await Promise.all(
Array.from({ length: userAmount }, async (_, i) => {
let user: User;
let type: 'human' | 'machine';
if (i % 2 === 0) {
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
type = 'human';
} else {
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
type = 'machine';
}
users.push(user);
await setUserMetadata(
[
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
],
user.userId,
tokens.accessToken!,
);
if (i % 10 === 0) {
console.log(`setup: ${i} of ${userAmount} users setup`);
}
}),
);
console.info(`setup: ${users.length} users created`);
return { tokens, org, users };
}
export default async function (data: any) {
const result = await listUsers(
{
queries: [
{ metadataValueFilter: { value: encoding.b64encode('human', 'rawurl'), method: 'BYTE_FILTER_METHOD_EQUALS' } },
],
},
data.tokens.accessToken!,
);
check(result, {
'total result length': (res) => res.details.totalResult == userAmount / 2,
}) || console.log(`unexpected amount of users. expected ${userAmount / 2} but got ${result.details.totalResult}`);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}
@@ -0,0 +1,77 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg } from '../org';
import { User, createMachine } from '../user';
import { Config, MaxVUs } from '../config';
import { createProject, Project } from '../project';
import { addUserGrant } from '../user_grant';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const projects = await Promise.all(
Array.from({ length: 50 }, (_, i) => {
return createProject(`project-${i}`, org, tokens.accessToken!);
}),
);
console.log(`setup: ${projects.length} projects created`);
let machines = (
await Promise.all(
Array.from({ length: MaxVUs() }, async (_, i) => {
return await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
}),
)
).map((machine) => {
return { userId: machine.userId, loginName: machine.loginNames[0] };
});
console.log(`setup: ${machines.length} machines created`);
return { tokens, org, machines, projects };
}
export default async function (data: any) {
const machine = await createMachine(`zitachine-${__VU}-${__ITER}`, data.org, data.tokens.accessToken!);
let userGrants = await Promise.all(
data.projects.map((project: Project) => {
return addUserGrant(data.org, machine.userId, project, [], data.tokens.accessToken!);
}),
);
return { userGrants };
}
export function teardown(data: any) {
// removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org is not removed to verify correctness of projections, do not forget to remove the org afterwards');
}
/**
* To verify the correctness of the projections you can use the following statements:
*
* set the owner of the events:
*
* set my.owner = '<org id of the created org>';
*
* check if the amount of events is the same as amount of objects
*
* select * from (
select 'projections.user_grants5', count(*) from projections.user_grants5 where resource_owner = (select current_setting('my.owner'))
union all
select 'projections.users14', count(*) from projections.users14 where resource_owner = (select current_setting('my.owner'))
union all
select 'projections.sessions8', count(*) from projections.sessions8 where user_resource_owner = (select current_setting('my.owner'))
union all
select aggregate_type, count(*) from eventstore.events2
where
aggregate_type in ('user', 'usergrant', 'session')
and event_type in ('user.machine.added', 'user.human.added', 'user.grant.added', 'session.user.checked')
and (owner = (select current_setting('my.owner'))
OR payload->>'userResourceOwner' = (select current_setting('my.owner'))
)
group by aggregate_type
) order by 2;
*/
+368
View File
@@ -0,0 +1,368 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http, { RefinedResponse } from 'k6/http';
import url from './url';
import { check } from 'k6';
import { Metadata } from './metadata';
export type User = {
userId: string;
loginName: string;
password: string;
};
export interface Human extends User {
loginNames: string[];
}
const createHumanTrend = new Trend('user_create_human_duration', true);
export function createHuman(username: string, org: Org, accessToken: string): Promise<Human> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2/users/human'),
JSON.stringify({
username: username,
organization: {
orgId: org.organizationId,
},
profile: {
givenName: 'Gigi',
familyName: 'Zitizen',
},
email: {
email: `${username}@zitadel.com`,
isVerified: true,
},
password: {
password: 'Password1!',
changeRequired: false,
},
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response
.then((res) => {
check(res, {
'create user is status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`);
createHumanTrend.add(res.timings.duration);
const user = http.get(url(`/v2/users/${res.json('userId')!}`), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
resolve(user.json('user')! as unknown as Human);
})
.catch((reason) => {
reject(reason);
});
});
}
const setEmailOTPOnHumanTrend = new Trend('set_human_email_otp_duration', true);
export async function setEmailOTPOnHuman(user: User, org: Org, accessToken: string): Promise<void> {
const response = await http.asyncRequest('POST', url(`/v2/users/${user.userId}/otp_email`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
check(response, {
'set email otp status ok': (r) => r.status >= 200 && r.status < 300,
});
setEmailOTPOnHumanTrend.add(response.timings.duration);
return;
}
const updateHumanTrend = new Trend('update_human_duration', true);
export function updateHuman(
payload: any = {},
userId: string,
org: Org,
accessToken: string,
): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('PUT', url(`/v2beta/users/${userId}`), JSON.stringify(payload), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status === 201,
});
updateHumanTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
export interface Machine extends User {
loginNames: string[];
}
const createMachineTrend = new Trend('user_create_machine_duration', true);
export function createMachine(username: string, org: Org, accessToken: string): Promise<Machine> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/management/v1/users/machine'),
JSON.stringify({
userName: username,
name: username,
// bearer
access_token_type: 0,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response
.then((res) => {
check(res, {
'create user is status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`);
createMachineTrend.add(res.timings.duration);
const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
resolve(user.json('user')! as unknown as Machine);
})
.catch((reason) => {
reject(reason);
});
});
}
export type MachinePat = {
token: string;
};
const addMachinePatTrend = new Trend('user_add_machine_pat_duration', true);
export function addMachinePat(userId: string, org: Org, accessToken: string): Promise<MachinePat> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/management/v1/users/${userId}/pats`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'add pat status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add pat (user id: ${userId}) status: ${res.status} body: ${res.body}`);
addMachinePatTrend.add(res.timings.duration);
resolve(res.json()! as MachinePat);
});
});
}
export type MachineSecret = {
clientId: string;
clientSecret: string;
};
const addMachineSecretTrend = new Trend('user_add_machine_secret_duration', true);
export function addMachineSecret(userId: string, org: Org, accessToken: string): Promise<MachineSecret> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('PUT', url(`/management/v1/users/${userId}/secret`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'generate machine secret status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to generate machine secret (user id: ${userId}) status: ${res.status} body: ${res.body}`);
addMachineSecretTrend.add(res.timings.duration);
resolve(res.json()! as MachineSecret);
});
});
}
export type MachineKey = {
keyId: string;
};
const addMachineKeyTrend = new Trend('user_add_machine_key_duration', true);
export function addMachineKey(userId: string, org: Org, accessToken: string, publicKey?: string): Promise<MachineKey> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/users/${userId}/keys`),
JSON.stringify({
type: 'KEY_TYPE_JSON',
userId: userId,
// base64 encoded public key
publicKey: publicKey,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'generate machine key status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to generate machine Key (user id: ${userId}) status: ${res.status} body: ${res.body}`);
addMachineKeyTrend.add(res.timings.duration);
resolve(res.json()! as MachineKey);
});
});
}
const lockUserTrend = new Trend('lock_user_duration', true);
export function lockUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2beta/users/${userId}/lock`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status >= 200 && r.status < 300,
});
lockUserTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
const deleteUserTrend = new Trend('delete_user_duration', true);
export function deleteUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('DELETE', url(`/v2beta/users/${userId}`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status === 201,
});
deleteUserTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
const setUserMetadataTrend = new Trend('set_user_metadata_duration', true);
export function setUserMetadata(metadata: Metadata[], userId: string, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2/users/${userId}/metadata`), JSON.stringify({ metadata: metadata }), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
response
.then((res) => {
check(res, {
'set user metadata is status ok': (r) => r.status >= 200 && r.status < 300,
}) || console.log(`unable to set user metadata (user id: ${userId}) status: ${res.status} body: ${res.body}`);
setUserMetadataTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
export type ListUsersRequest = {
queries: {
metadataKeyFilter?: {
key: string;
method: 'TEXT_FILTER_METHOD_EQUALS' | 'TEXT_FILTER_METHOD_CONTAINS' | 'TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE';
};
metadataValueFilter?: {
value: string;
method: 'BYTE_FILTER_METHOD_EQUALS' | 'BYTE_FILTER_METHOD_NOT_EQUALS';
};
}[];
};
export type ListUsersResult = {
details: {
totalResult: number;
};
};
const listUsersTrend = new Trend('list_users_duration', true);
export function listUsers(body: ListUsersRequest, accessToken: string): Promise<ListUsersResult> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2/users`), JSON.stringify(body), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
response
.then((res) => {
check(res, {
'list users is status ok': (r) => r.status >= 200 && r.status < 300,
}) || console.log(`unable to list users status: ${res.status} body: ${res.body}`);
listUsersTrend.add(res.timings.duration);
resolve(res.json()! as ListUsersResult);
})
.catch((reason) => {
reject(reason);
});
});
}
+46
View File
@@ -0,0 +1,46 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { check, fail } from 'k6';
import { Project } from './project';
import { Org } from './org';
export type UserGrant = {
userGrantId: string;
};
const addUserGrantTrend = new Trend('user_grant_add', true);
export async function addUserGrant(
org: Org,
userId: string,
project: Project,
roles: string[],
accessToken: string,
): Promise<UserGrant> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/users/${userId}/grants`),
JSON.stringify({
projectId: project.id,
roleKeys: roles,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add User Grant status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add User Grant status: ${res.status} body: ${res.body}`);
addUserGrantTrend.add(res.timings.duration);
resolve(res.json() as UserGrant);
});
});
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES6",
"moduleResolution": "node",
"module": "commonjs",
"noEmit": true,
"allowJs": true,
"removeComments": false,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
}
}
+40
View File
@@ -0,0 +1,40 @@
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const GlobEntries = require('webpack-glob-entries');
module.exports = {
mode: 'production',
entry: GlobEntries('./src/use_cases/**/*.ts'), // Generates multiple entry for each test
output: {
path: path.join(__dirname, 'dist'),
libraryTarget: 'commonjs',
filename: '[name].js',
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
use: 'babel-loader',
exclude: /node_modules/,
},
],
},
target: 'web',
externals: /^(k6|https?\:\/\/)(\/.*)?/,
// Generate map files for compiled scripts
devtool: "source-map",
stats: {
colors: true,
},
plugins: [
new CleanWebpackPlugin(),
],
optimization: {
// Don't minimize, as it's not used in the browser
minimize: false,
},
};