mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-04 23:45:07 +00:00
chore: init load tests (#7635)
* init load tests * add machine pat * setup app * add introspect * use xk6-modules repo * logging * add teardown * add manipulate user * add manipulate user * remove logs * convert tests to ts * add readme * zitadel * review comments
This commit is contained in:
parent
dbb824a73f
commit
d337668599
4
.gitignore
vendored
4
.gitignore
vendored
@ -82,3 +82,7 @@ go.work
|
||||
go.work.sum
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
load-test/node_modules
|
||||
load-test/yarn-error.log
|
||||
load-test/dist
|
8
load-test/.babelrc
Normal file
8
load-test/.babelrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"presets": ["@babel/env", "@babel/typescript"],
|
||||
"plugins": [
|
||||
"@babel/proposal-class-properties",
|
||||
|
||||
"@babel/proposal-object-rest-spread"
|
||||
]
|
||||
}
|
5
load-test/.prettierrc
Normal file
5
load-test/.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 125,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
45
load-test/Makefile
Normal file
45
load-test/Makefile
Normal file
@ -0,0 +1,45 @@
|
||||
VUS ?= 20
|
||||
DURATION ?= "200s"
|
||||
ZITADEL_HOST ?=
|
||||
ADMIN_LOGIN_NAME ?=
|
||||
ADMIN_PASSWORD ?=
|
||||
|
||||
.PHONY: human_password_login
|
||||
human_password_login: bundle
|
||||
k6 run dist/human_password_login.js --vus ${VUS} --duration ${DURATION}
|
||||
|
||||
.PHONY: machine_pat_login
|
||||
machine_pat_login: bundle
|
||||
k6 run dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION}
|
||||
|
||||
.PHONY: user_info
|
||||
user_info: bundle
|
||||
k6 run dist/user_info.js --vus ${VUS} --duration ${DURATION}
|
||||
|
||||
.PHONY: manipulate_user
|
||||
manipulate_user: bundle
|
||||
k6 run dist/manipulate_user.js --vus ${VUS} --duration ${DURATION}
|
||||
|
||||
.PHONY: introspect
|
||||
introspect: ensure_modules bundle
|
||||
go install go.k6.io/xk6/cmd/xk6@latest
|
||||
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
|
||||
./../../xk6-modules/k6 run dist/introspection.js --vus ${VUS} --duration ${DURATION}
|
||||
|
||||
.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:
|
||||
npm i
|
||||
npm run bundle
|
46
load-test/README.md
Normal file
46
load-test/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Load Tests
|
||||
|
||||
This package contains code for load testing specific endpoints of ZITADEL 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 ZITADEL
|
||||
|
||||
## 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`)
|
||||
|
||||
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`: `zitadel-admin@zitadel.localhost`
|
||||
- `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 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
|
4529
load-test/package-lock.json
generated
Normal file
4529
load-test/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
load-test/package.json
Normal file
33
load-test/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "typescript",
|
||||
"version": "1.0.0",
|
||||
"repository": "ssh://git@github.com/zitadel/zitadel.git",
|
||||
"author": "ZITADEL Authors <hi@zitadel.com>",
|
||||
"engines": {
|
||||
"node": "16 || 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": ">=0.50.0",
|
||||
"@types/webpack": "5.28.5",
|
||||
"babel-loader": "9.1.3",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"typescript": "5.4.5",
|
||||
"webpack": "5.89.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-glob-entries": "^1.0.1",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "webpack",
|
||||
"lint": "prettier --check src",
|
||||
"lint:fix": "prettier --write src"
|
||||
}
|
||||
}
|
70
load-test/src/app.ts
Normal file
70
load-test/src/app.ts
Normal 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,
|
||||
}) || 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,
|
||||
}) || 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
load-test/src/config.ts
Normal file
67
load-test/src/config.ts
Normal 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 || 'zitadel-admin@zitadel.localhost',
|
||||
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;
|
||||
}
|
113
load-test/src/login_ui.ts
Normal file
113
load-test/src/login_ui.ts
Normal file
@ -0,0 +1,113 @@
|
||||
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);
|
||||
function initLogin(): Response {
|
||||
const response = http.get(url('/oauth/v2/authorize', { searchParams: Client() }));
|
||||
check(response, {
|
||||
'authorize status ok': (r) => r.status == 200 || fail(`init login failed: ${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) || 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 || 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 || 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;
|
||||
}
|
74
load-test/src/oidc.ts
Normal file
74
load-test/src/oidc.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { JSONObject, check, fail } from 'k6';
|
||||
import encoding from 'k6/encoding';
|
||||
import http from 'k6/http';
|
||||
import { Trend } from 'k6/metrics';
|
||||
import url from './url';
|
||||
|
||||
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 || 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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
introspectTrend.add(res.timings.duration);
|
||||
}
|
56
load-test/src/org.ts
Normal file
56
load-test/src/org.ts
Normal 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('/v2beta/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 === 201;
|
||||
},
|
||||
}) || 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,
|
||||
}) || console.log(`status: ${response.status} || body: ${response.body}|| org: ${JSON.stringify(org)}`);
|
||||
|
||||
return response.json();
|
||||
}
|
37
load-test/src/project.ts
Normal file
37
load-test/src/project.ts
Normal 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,
|
||||
}) || reject(`unable to add project status: ${res.status} body: ${res.body}`);
|
||||
|
||||
addProjectTrend.add(res.timings.duration);
|
||||
resolve(res.json() as Project);
|
||||
});
|
||||
});
|
||||
}
|
18
load-test/src/url.ts
Normal file
18
load-test/src/url.ts
Normal 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();
|
||||
}
|
37
load-test/src/use_cases/human_password_login.ts
Normal file
37
load-test/src/use_cases/human_password_login.ts
Normal file
@ -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
load-test/src/use_cases/introspection.ts
Normal file
54
load-test/src/use_cases/introspection.ts
Normal 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');
|
||||
}
|
46
load-test/src/use_cases/machine_pat_login.ts
Normal file
46
load-test/src/use_cases/machine_pat_login.ts
Normal file
@ -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');
|
||||
}
|
47
load-test/src/use_cases/manipulate_user.ts
Normal file
47
load-test/src/use_cases/manipulate_user.ts
Normal file
@ -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}`, 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');
|
||||
}
|
27
load-test/src/use_cases/user_info.ts
Normal file
27
load-test/src/use_cases/user_info.ts
Normal 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');
|
||||
}
|
222
load-test/src/user.ts
Normal file
222
load-test/src/user.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { Trend } from 'k6/metrics';
|
||||
import { Org } from './org';
|
||||
import http, { RefinedResponse } from 'k6/http';
|
||||
import url from './url';
|
||||
import { check } from 'k6';
|
||||
|
||||
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('/v2beta/users/human'),
|
||||
JSON.stringify({
|
||||
username: username,
|
||||
organization: {
|
||||
orgId: org.organizationId,
|
||||
},
|
||||
profile: {
|
||||
givenName: 'Gigi',
|
||||
familyName: 'Zitizen',
|
||||
},
|
||||
email: {
|
||||
email: `zitizen-@caos.ch`,
|
||||
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 === 201,
|
||||
}) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`);
|
||||
createHumanTrend.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 Human);
|
||||
})
|
||||
.catch((reason) => {
|
||||
reject(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
}) || 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,
|
||||
}) || reject(`unable to add pat (user id: ${userId}) status: ${res.status} body: ${res.body}`);
|
||||
|
||||
addMachinePatTrend.add(res.timings.duration);
|
||||
resolve(res.json()! as MachinePat);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 === 201,
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
26
load-test/tsconfig.json
Normal file
26
load-test/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
48
load-test/webpack.config.js
Normal file
48
load-test/webpack.config.js
Normal file
@ -0,0 +1,48 @@
|
||||
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(),
|
||||
// Copy assets to the destination folder
|
||||
// see `src/post-file-test.ts` for an test example using an asset
|
||||
new CopyPlugin({
|
||||
patterns: [{
|
||||
from: path.resolve(__dirname, 'assets'),
|
||||
noErrorOnMissing: true
|
||||
}],
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
// Don't minimize, as it's not used in the browser
|
||||
minimize: false,
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue
Block a user