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:
Silvan 2024-04-18 11:21:07 +02:00 committed by GitHub
parent dbb824a73f
commit d337668599
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 5612 additions and 0 deletions

4
.gitignore vendored
View File

@ -82,3 +82,7 @@ go.work
go.work.sum go.work.sum
# Local Netlify folder # Local Netlify folder
.netlify .netlify
load-test/node_modules
load-test/yarn-error.log
load-test/dist

8
load-test/.babelrc Normal file
View 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
View File

@ -0,0 +1,5 @@
{
"printWidth": 125,
"singleQuote": true,
"trailingComma": "all"
}

45
load-test/Makefile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

33
load-test/package.json Normal file
View 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
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,
}) || 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
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 || '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
View 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
View 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
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('/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
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,
}) || 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
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();
}

View 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);
}

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');
}

View 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');
}

View 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');
}

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');
}

222
load-test/src/user.ts Normal file
View 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
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
}
}

View 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,
},
};