diff --git a/README.md b/README.md index c4aa02d..af67cff 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,26 @@ Load and performance tests for the schulportal -## +## Running in the cluster + +Go to Actions > Trigger Loadtest > Run workflow. + +In the dialog enter the following and replace the UPPERCASE words in the right column with the appropriate values (see explanation below). + +| Use workflow from | DBP-1012-setup-loadtest-env | +| Branch to take tests and helm/cron setup from | main | +| sets PATTERN env var used as k6 input | PATTERN | +| sets CONFIG env var used as k6 input | CONFIG | +| name of test scenario defined in values.yaml | SCENARIO | +| execute cronjob scenario after install | true | + +### Values explained + +| PATTERN | a glob that matches a file in `loadtest/usecases`, i.e. `1` or `login` | +| CONFIG | one of spike, stress, breakpoint, debug; see `loadtest/util/config.ts` | +| SCENARIO | target environment; one of dev-scenario, staging-scenario, prod-scenario; see `charts/schulportal-load-tests/values.yaml` | + +## Local ### Setup @@ -49,3 +68,9 @@ npm run check # to format, lint, typecheck the code ```sh npm run generate-client # to generate new type-definitions for the api ``` + +```sh + # k6 requires filetypes on some imports and won't run without them. + # The error does not give you a lot to go on, but this will list all locations where these extensions are missing. +./find-bad-imports.sh +``` diff --git a/charts/schulportal-load-tests/values.yaml b/charts/schulportal-load-tests/values.yaml index 227c344..7ebc804 100644 --- a/charts/schulportal-load-tests/values.yaml +++ b/charts/schulportal-load-tests/values.yaml @@ -22,6 +22,7 @@ cronjobs: staging-scenario: serviceName: staging-scenario spsh_base: "https://spsh.staging.spsh.dbildungsplattform.de" + kc_base: "https://keycloak.staging.spsh.dbildungsplattform.de" # prod-scenario: # serviceName: prod-scenario # image: ghcr.io/dbildungsplattform/schulportal-load-tests:latest diff --git a/find-bad-imports.sh b/find-bad-imports.sh new file mode 100644 index 0000000..4ebe146 --- /dev/null +++ b/find-bad-imports.sh @@ -0,0 +1,2 @@ +#!/usr/bin/bash +grep -e "from \"" loadtest/**/*.ts | grep -ve ".*ts\";$" | grep -ve "k6" \ No newline at end of file diff --git a/loadtest/api-client/openapispec.json b/loadtest/api-client/openapispec.json index d338744..7235f36 100644 --- a/loadtest/api-client/openapispec.json +++ b/loadtest/api-client/openapispec.json @@ -1855,7 +1855,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RolleServiceProviderQueryParams" + "$ref": "#/components/schemas/RolleServiceProviderBodyParams" } } } @@ -1901,19 +1901,18 @@ "in": "path", "description": "The id for the rolle.", "schema": { "nullable": false, "type": "string" } - }, - { - "name": "serviceProviderIds", - "required": true, - "in": "query", - "description": "An array of ids for the service providers.", - "schema": { - "nullable": false, - "type": "array", - "items": { "type": "string" } - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolleServiceProviderBodyParams" + } + } + } + }, "responses": { "200": { "description": "Removing service-provider finished successfully." @@ -2814,6 +2813,206 @@ "tags": ["2FA"], "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] } + }, + "/api/cron/kopers-lock": { + "put": { + "operationId": "CronController_koPersUserLock", + "parameters": [], + "responses": { + "201": { + "description": "User were successfully locked.", + "content": { + "application/json": { "schema": { "type": "boolean" } } + } + }, + "400": { "description": "User are not given or not found" }, + "401": { "description": "Not authorized to lock user." }, + "403": { "description": "Insufficient permissions to lock user." }, + "404": { "description": "Insufficient permissions to lock user." }, + "500": { + "description": "Internal server error while trying to lock user." + } + }, + "tags": ["cron"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } + }, + "/api/cron/kontext-expired": { + "put": { + "operationId": "CronController_removePersonenKontexteWithExpiredBefristungFromUsers", + "parameters": [], + "responses": { + "201": { + "description": "Personenkontexte were successfully removed from users.", + "content": { + "application/json": { "schema": { "type": "boolean" } } + } + }, + "400": { + "description": "Personenkontexte are not given or not found." + }, + "401": { + "description": "Not authorized to remove personenkontexte from users." + }, + "403": { + "description": "Insufficient permissions to remove personenkontexte from users." + }, + "404": { + "description": "Insufficient permissions to remove personenkontexte from users." + }, + "500": { + "description": "Internal server error while trying to remove personenkontexte from users." + } + }, + "tags": ["cron"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } + }, + "/api/cron/person-without-org": { + "put": { + "operationId": "CronController_personWithoutOrgDelete", + "parameters": [], + "responses": { + "201": { + "description": "User were successfully removed.", + "content": { + "application/json": { "schema": { "type": "boolean" } } + } + }, + "400": { "description": "User are not given or not found" }, + "401": { "description": "Not authorized to remove user." }, + "403": { "description": "Insufficient permissions to delete user." }, + "404": { "description": "Insufficient permissions to delete user." }, + "500": { + "description": "Internal server error while trying to remove user." + } + }, + "tags": ["cron"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } + }, + "/api/import/upload": { + "post": { + "operationId": "ImportController_uploadFile", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/DbiamPersonenkontextImportBodyParams" + } + } + } + }, + "responses": { + "200": { + "description": "Returns an import upload response object.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportUploadResponse" + } + } + } + }, + "400": { "description": "The CSV file was not valid." }, + "401": { + "description": "Not authorized to import data with a CSV file." + }, + "403": { + "description": "Insufficient permissions to import data with a CSV file." + }, + "500": { + "description": "Internal server error while importing data with a CSV file." + } + }, + "tags": ["import"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } + }, + "/api/import/execute": { + "post": { + "operationId": "ImportController_executeImport", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportvorgangByIdBodyParams" + } + } + } + }, + "responses": { + "200": { + "description": "Import transaction was executed successfully. The text file can be downloaded", + "content": { + "text/plain": { + "schema": { "type": "string", "format": "binary" } + } + } + }, + "400": { + "description": "Something went wrong with the found import transaction.", + "content": { + "text/plain": { + "schema": { "$ref": "#/components/schemas/DbiamImportError" } + } + } + }, + "401": { + "description": "Not authorized to execute the import transaction." + }, + "403": { + "description": "Insufficient permissions to execute the import transaction." + }, + "404": { "description": "The import transaction does not exist." }, + "500": { + "description": "Internal server error while executing the import transaction." + } + }, + "tags": ["import"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } + }, + "/api/import/{importvorgangId}": { + "delete": { + "operationId": "ImportController_deleteImportTransaction", + "summary": "", + "description": "Delete a role by id.", + "parameters": [ + { + "name": "importvorgangId", + "required": true, + "in": "path", + "description": "The id of an import transaction", + "schema": { "nullable": false, "type": "string" } + } + ], + "responses": { + "204": { + "description": "Import transaction was deleted successfully." + }, + "400": { + "description": "Something went wrong with the found import transaction.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DbiamImportError" } + } + } + }, + "401": { + "description": "Not authorized to delete the import transaction." + }, + "404": { + "description": "The import transaction that should be deleted does not exist." + } + }, + "tags": ["import"], + "security": [{ "oauth2": ["openid"] }, { "bearer": [] }] + } } }, "info": { @@ -2889,7 +3088,8 @@ "items": { "$ref": "#/components/schemas/PersonenkontextRolleFieldsResponse" } - } + }, + "acr": { "type": "string", "nullable": false } }, "required": [ "sub", @@ -2912,7 +3112,8 @@ "phone_number", "updated_at", "password_updated_at", - "personenkontexte" + "personenkontexte", + "acr" ] }, "CreatePersonMigrationBodyParams": { @@ -2954,6 +3155,28 @@ "type": "string", "enum": ["KEIN", "UNBE", "TEIL", "VOLL"] }, + "UserLockParams": { + "type": "object", + "properties": { + "personId": { "type": "string", "nullable": true }, + "locked_by": { "type": "string", "nullable": true }, + "created_at": { "type": "string", "nullable": true }, + "locked_until": { "type": "string", "nullable": true } + }, + "required": ["personId", "locked_by", "created_at", "locked_until"] + }, + "EmailAddressStatus": { + "type": "string", + "enum": ["ENABLED", "DISABLED", "REQUESTED", "FAILED"] + }, + "PersonEmailResponse": { + "type": "object", + "properties": { + "status": { "$ref": "#/components/schemas/EmailAddressStatus" }, + "address": { "type": "string" } + }, + "required": ["status", "address"] + }, "PersonResponse": { "type": "object", "properties": { @@ -2979,11 +3202,19 @@ }, "personalnummer": { "type": "string", "nullable": true }, "isLocked": { "type": "boolean", "nullable": true }, - "lockInfo": { "type": "object", "nullable": true }, + "userLock": { + "nullable": true, + "allOf": [{ "$ref": "#/components/schemas/UserLockParams" }] + }, "lastModified": { "format": "date-time", "type": "string", "description": "Date of the most recent changes for the person" + }, + "email": { + "nullable": true, + "description": "Contains status and address. Returns email-address verified by OX (enabled) if available, otherwise returns most recently updated one (no prioritized status)", + "allOf": [{ "$ref": "#/components/schemas/PersonEmailResponse" }] } }, "required": [ @@ -3000,8 +3231,9 @@ "startpasswort", "personalnummer", "isLocked", - "lockInfo", - "lastModified" + "userLock", + "lastModified", + "email" ] }, "PersonendatensatzResponse": { @@ -3109,9 +3341,14 @@ "type": "object", "properties": { "lock": { "type": "boolean", "nullable": false }, - "locked_from": { "type": "string", "nullable": false } + "locked_by": { "type": "string", "nullable": false }, + "locked_until": { + "format": "date-time", + "type": "string", + "description": "Required if Befristung is set" + } }, - "required": ["lock", "locked_from"] + "required": ["lock", "locked_by"] }, "PersonLockResponse": { "type": "object", @@ -3362,7 +3599,8 @@ "namensergaenzung": { "type": "string" }, "kuerzel": { "type": "string" }, "typ": { "$ref": "#/components/schemas/OrganisationsTyp" }, - "traegerschaft": { "$ref": "#/components/schemas/TraegerschaftTyp" } + "traegerschaft": { "$ref": "#/components/schemas/TraegerschaftTyp" }, + "emailAdress": { "type": "string" } }, "required": ["name", "typ"] }, @@ -3409,7 +3647,8 @@ "ORGANISATION_IST_BEREITS_ZUGEWIESEN_ERROR", "NAME_REQUIRED_FOR_KLASSE", "NAME_ENTHAELT_LEERZEICHEN", - "KENNUNG_ENTHAELT_LEERZEICHEN" + "KENNUNG_ENTHAELT_LEERZEICHEN", + "EMAIL_ADRESS_ON_ORGANISATION_TYP" ] }, "code": { @@ -3432,7 +3671,8 @@ "namensergaenzung": { "type": "string" }, "kuerzel": { "type": "string" }, "typ": { "$ref": "#/components/schemas/OrganisationsTyp" }, - "traegerschaft": { "$ref": "#/components/schemas/TraegerschaftTyp" } + "traegerschaft": { "$ref": "#/components/schemas/TraegerschaftTyp" }, + "emailAdress": { "type": "string" } }, "required": ["name", "typ"] }, @@ -3478,7 +3718,9 @@ "KLASSEN_VERWALTEN", "SCHULTRAEGER_VERWALTEN", "MIGRATION_DURCHFUEHREN", - "PERSON_SYNCHRONISIEREN" + "PERSON_SYNCHRONISIEREN", + "CRON_DURCHFUEHREN", + "IMPORT_DURCHFUEHREN" ] }, "OrganisationByIdBodyParams": { @@ -3557,6 +3799,7 @@ "type": "string", "nullable": true }, + "version": { "type": "number" }, "serviceProviders": { "type": "array", "items": { @@ -3575,6 +3818,7 @@ "systemrechte", "administeredBySchulstrukturknotenName", "administeredBySchulstrukturknotenKennung", + "version", "serviceProviders" ] }, @@ -3629,7 +3873,8 @@ "administeredBySchulstrukturknotenKennung": { "type": "string", "nullable": true - } + }, + "version": { "type": "number" } }, "required": [ "id", @@ -3641,7 +3886,8 @@ "merkmale", "systemrechte", "administeredBySchulstrukturknotenName", - "administeredBySchulstrukturknotenKennung" + "administeredBySchulstrukturknotenKennung", + "version" ] }, "AddSystemrechtBodyParams": { @@ -3661,7 +3907,7 @@ }, "required": ["serviceProviderIds"] }, - "RolleServiceProviderQueryParams": { + "RolleServiceProviderBodyParams": { "type": "object", "properties": { "serviceProviderIds": { @@ -3669,9 +3915,13 @@ "nullable": false, "type": "array", "items": { "type": "string" } + }, + "version": { + "type": "number", + "description": "The version for the rolle." } }, - "required": ["serviceProviderIds"] + "required": ["serviceProviderIds", "version"] }, "ServiceProviderTarget": { "type": "string", @@ -3725,9 +3975,16 @@ "uniqueItems": true, "type": "array", "items": { "type": "string" } - } + }, + "version": { "type": "number" } }, - "required": ["name", "merkmale", "systemrechte", "serviceProviderIds"] + "required": [ + "name", + "merkmale", + "systemrechte", + "serviceProviderIds", + "version" + ] }, "DbiamRolleError": { "type": "object", @@ -3739,7 +3996,8 @@ "ADD_SYSTEMRECHT_ERROR", "ROLLE_HAT_PERSONENKONTEXTE_ERROR", "UPDATE_MERKMALE_ERROR", - "ROLLENNAME_ENTHAELT_LEERZEICHEN" + "ROLLENNAME_ENTHAELT_LEERZEICHEN", + "NEWER_VERSION_OF_ROLLE_AVAILABLE" ] }, "code": { @@ -4146,6 +4404,90 @@ "type": "object", "properties": { "required": { "type": "boolean" } }, "required": ["required"] + }, + "DbiamPersonenkontextImportBodyParams": { + "type": "object", + "properties": { + "organisationId": { "type": "string" }, + "rolleId": { "type": "string" }, + "file": { "type": "string", "format": "binary" } + }, + "required": ["organisationId", "rolleId", "file"] + }, + "ImportDataItemResponse": { + "type": "object", + "properties": { + "nachname": { "type": "string" }, + "vorname": { "type": "string" }, + "klasse": { "type": "string", "nullable": true }, + "validationErrors": { "type": "array", "items": { "type": "string" } } + }, + "required": ["nachname", "vorname", "klasse", "validationErrors"] + }, + "ImportUploadResponse": { + "type": "object", + "properties": { + "importvorgangId": { + "type": "string", + "description": "The import transaction number. it will be needed to execute the import and download the result" + }, + "isValid": { + "type": "boolean", + "description": "It states if the import transaction contain errors." + }, + "totalImportDataItems": { + "type": "number", + "description": "The total number of data items in the CSV file." + }, + "totalInvalidImportDataItems": { + "type": "number", + "description": "The total number of data items in the CSV file that are invalid." + }, + "invalidImportDataItems": { + "type": "array", + "items": { "$ref": "#/components/schemas/ImportDataItemResponse" } + } + }, + "required": [ + "importvorgangId", + "isValid", + "totalImportDataItems", + "totalInvalidImportDataItems", + "invalidImportDataItems" + ] + }, + "ImportvorgangByIdBodyParams": { + "type": "object", + "properties": { + "importvorgangId": { + "type": "string", + "description": "The id of an import transaction", + "nullable": false + }, + "organisationId": { "type": "string" }, + "rolleId": { "type": "string" } + }, + "required": ["importvorgangId", "organisationId", "rolleId"] + }, + "DbiamImportError": { + "type": "object", + "properties": { + "i18nKey": { + "type": "string", + "enum": [ + "IMPORT_ERROR", + "CSV_PARSING_ERROR", + "CSV_FILE_EMPTY_ERROR", + "IMPORT_TEXT_FILE_CREATION_ERROR", + "IMPORT_NUR_LERN_AN_SCHULE_ERROR" + ] + }, + "code": { + "type": "number", + "description": "Corresponds to HTTP Status code like 200, 404, 500" + } + }, + "required": ["i18nKey", "code"] } } } diff --git a/loadtest/pages/index.ts b/loadtest/pages/index.ts index 0b897a2..c028d18 100644 --- a/loadtest/pages/index.ts +++ b/loadtest/pages/index.ts @@ -1,5 +1,12 @@ +import { getLogout } from "../util/api.ts"; + export interface PageObject { name: string; url: string; navigate: () => void; } + +export function logout() { + const res = getLogout(); + return res; +} diff --git a/loadtest/pages/profile.ts b/loadtest/pages/profile.ts new file mode 100644 index 0000000..98ee0d8 --- /dev/null +++ b/loadtest/pages/profile.ts @@ -0,0 +1,38 @@ +import { group } from "k6"; +import { + getLoginInfo, + getPersonenUebersichtById, + getPersonInfo, + getTwoFactorRequired, + getTwoFactorState, +} from "../util/api.ts"; +import { getFrontendUrl } from "../util/config.ts"; +import { loadPage } from "../util/page.ts"; +import { PageObject } from "./index.ts"; + +export class ProfilePage implements PageObject { + name = "UserDetails"; + url: string; + + constructor(public personId: string) { + this.url = `${getFrontendUrl()}profile`; + } + + navigate(): void { + loadPage(this.url, this.name); + this.fetchData(); + } + + fetchData() { + group("fetch profile", () => { + getLoginInfo(); + getPersonInfo(); + getPersonenUebersichtById(this.personId); + }); + + group("fetch 2fa info", () => { + getTwoFactorRequired([`personId=${this.personId}`]); + getTwoFactorState([`personId=${this.personId}`]); + }); + } +} diff --git a/loadtest/pages/start.ts b/loadtest/pages/start.ts index c54d503..ca6a168 100644 --- a/loadtest/pages/start.ts +++ b/loadtest/pages/start.ts @@ -7,7 +7,6 @@ import { getFrontendUrl } from "../util/config.ts"; import { loadPage } from "../util/page.ts"; import { PageObject } from "./index.ts"; -// class StartPage extends PageObject { class StartPage implements PageObject { name = "Start"; url = getFrontendUrl(); diff --git a/loadtest/pages/user-details.ts b/loadtest/pages/user-details.ts new file mode 100644 index 0000000..67863e3 --- /dev/null +++ b/loadtest/pages/user-details.ts @@ -0,0 +1,46 @@ +import { group } from "k6"; +import { DBiamPersonenzuordnungResponse } from "../api-client/generated/index.ts"; +import { + getLoginInfo, + getParentOrganisationenByIds, + getPersonById, + getPersonenkontextWorkflowStep, + getPersonenUebersichtById, + getTwoFactorRequired, + getTwoFactorState, +} from "../util/api.ts"; +import { getFrontendUrl } from "../util/config.ts"; +import { loadPage } from "../util/page.ts"; +import { PageObject } from "./index.ts"; + +export class UserDetailsPage implements PageObject { + name = "UserDetails"; + url: string; + + constructor(public personId: string) { + this.url = `${getFrontendUrl()}admin/personen/${personId}`; + } + + navigate(): void { + loadPage(this.url, this.name); + this.fetchData(); + } + + fetchData() { + group("fetch details", () => { + getLoginInfo(); + getPersonById(this.personId); + const { zuordnungen } = getPersonenUebersichtById(this.personId); + getPersonenkontextWorkflowStep(["limit=25"]); + const organisationIds = zuordnungen.map( + (z: DBiamPersonenzuordnungResponse) => z.sskId, + ); + getParentOrganisationenByIds(organisationIds); + }); + + group("fetch 2fa info", () => { + getTwoFactorRequired([`personId=${this.personId}`]); + getTwoFactorState([`personId=${this.personId}`]); + }); + } +} diff --git a/loadtest/pages/user-list.ts b/loadtest/pages/user-list.ts index 5816077..bd7d22f 100644 --- a/loadtest/pages/user-list.ts +++ b/loadtest/pages/user-list.ts @@ -1,3 +1,8 @@ +import { + DBiamPersonenuebersichtResponse, + OrganisationResponse, + RolleWithServiceProvidersResponse, +} from "../api-client/generated/index.ts"; import { getLoginInfo, getOrganisationen, @@ -9,6 +14,11 @@ import { getFrontendUrl } from "../util/config.ts"; import { loadPage } from "../util/page.ts"; import { PageObject } from "./index.ts"; +type FetchedData = { + organisationen: OrganisationResponse[]; + personenuebersichten: DBiamPersonenuebersichtResponse[]; + rollen: RolleWithServiceProvidersResponse[]; +}; class UserListPage implements PageObject { name = "UserList"; url = `${getFrontendUrl()}admin/personen`; @@ -19,7 +29,7 @@ class UserListPage implements PageObject { } // fetch on initial load - fetchData() { + fetchData(): FetchedData { getLoginInfo(); const organisationen = getOrganisationen([ "limit=25", @@ -35,11 +45,7 @@ class UserListPage implements PageObject { } const rollen = getRollen(["rolleName="]); - return { - organisationen, - personenuebersichten, - rollen, - }; + return { organisationen, personenuebersichten, rollen }; } } diff --git a/loadtest/usecases/1_goto-sp-oidc.ts b/loadtest/usecases/1_goto-sp-oidc.ts index 5ba2a4c..506b845 100644 --- a/loadtest/usecases/1_goto-sp-oidc.ts +++ b/loadtest/usecases/1_goto-sp-oidc.ts @@ -1,5 +1,6 @@ -import { fail, sleep } from "k6"; +import { check, fail, group } from "k6"; import { getDefaultOptions } from "../util/config.ts"; +import { prettyLog } from "../util/debug.ts"; import { loadPage, login } from "../util/page.ts"; import { wrapTestFunction } from "../util/usecase-wrapper.ts"; import { UserMix } from "../util/users.ts"; @@ -12,10 +13,30 @@ const serviceProviderName = "E-Mail"; export default wrapTestFunction(main); -function main(users = new UserMix({ LEHR: 1 })) { +function main(users = new UserMix({ LEHR: 1000 })) { const { providers } = login(users.getLogin()); - const target = providers.find((p) => p.name == serviceProviderName); - if (!target) fail(`could not find sp ${serviceProviderName}`); - loadPage(target.url); - sleep(1); + group("go to sp", () => { + const target = providers.find((p) => p.name == serviceProviderName); + if (!check(target, { "service provider found": (t) => t != undefined })) + fail(`could not find sp ${serviceProviderName}`); + const response = loadPage(target!.url); + check(response, { + "arrived at ox": (r) => { + try { + return r.url.includes("ox"); + } catch (error) { + prettyLog(error); + return false; + } + }, + "did not end at kc": (r) => { + try { + return !(r.url.includes("keycloak") || r.url.includes("auth")); + } catch (error) { + prettyLog(error); + return false; + } + }, + }); + }); } diff --git a/loadtest/usecases/2_create-new-user.ts b/loadtest/usecases/2_create-delete-new-user.ts similarity index 71% rename from loadtest/usecases/2_create-new-user.ts rename to loadtest/usecases/2_create-delete-new-user.ts index e7127f2..325747f 100644 --- a/loadtest/usecases/2_create-new-user.ts +++ b/loadtest/usecases/2_create-delete-new-user.ts @@ -1,20 +1,32 @@ import { group, sleep } from "k6"; +import { logout } from "../pages/index.ts"; +import { UserDetailsPage } from "../pages/user-details.ts"; +import { userListPage } from "../pages/user-list.ts"; import { + deletePersonById, getAdministeredOrganisationenById, getLoginInfo, getPersonenkontextWorkflowStep, postPersonenkontextWorkflow, } from "../util/api.ts"; import { getDefaultOptions } from "../util/config.ts"; -import { getRandomName, pickRandomItem } from "../util/data.ts"; -import { goToUserList } from "../util/page.ts"; +import { getFutureDate, getRandomName, pickRandomItem } from "../util/data.ts"; +import { goToUserList, login } from "../util/page.ts"; +import { deleteAllTestUsers } from "../util/resource-helper.ts"; import { wrapTestFunction } from "../util/usecase-wrapper.ts"; -import { getDefaultAdminMix } from "../util/users.ts"; +import { getDefaultAdminMix, UserMix } from "../util/users.ts"; export const options = { ...getDefaultOptions(), }; +export function teardown() { + const admin = new UserMix({ SYSADMIN: 1 }); + login(admin.getLogin()); + deleteAllTestUsers(); + logout(); +} + export default wrapTestFunction(main); function main(users = getDefaultAdminMix()) { @@ -24,7 +36,7 @@ function main(users = getDefaultAdminMix()) { getLoginInfo(); }); - group("go through workflow", () => { + const createdPerson = group("go through creation workflow", () => { const { organisations } = getPersonenkontextWorkflowStep(["limit=25"]); const organisation = pickRandomItem(organisations); typeIntoAutocomplete(organisation.name, (name) => { @@ -55,7 +67,7 @@ function main(users = getDefaultAdminMix()) { const body = { ...getRandomName(), personalnummer: "", - befristung: new Date("2055-07-31T22:00:00.000Z"), + befristung: getFutureDate(), createPersonenkontexte: [ { organisationId: organisation.id, @@ -79,7 +91,16 @@ function main(users = getDefaultAdminMix()) { } else { body.personalnummer = "1237562"; } - postPersonenkontextWorkflow(body); + return postPersonenkontextWorkflow(body); + }); + + group("navigate back", () => { + userListPage.navigate(); + new UserDetailsPage(createdPerson.person.id).navigate(); + }); + + group("go through deletion workflow", () => { + deletePersonById(createdPerson.person.id); }); } diff --git a/loadtest/usecases/2_goto-sp-saml.ts b/loadtest/usecases/2_goto-sp-saml.ts index 99b88b9..dad36da 100644 --- a/loadtest/usecases/2_goto-sp-saml.ts +++ b/loadtest/usecases/2_goto-sp-saml.ts @@ -1,8 +1,9 @@ -import { fail, sleep } from "k6"; +import { check, fail, group } from "k6"; +import { get } from "k6/http"; import { getDefaultOptions } from "../util/config.ts"; import { loadPage, login } from "../util/page.ts"; import { wrapTestFunction } from "../util/usecase-wrapper.ts"; -import { getDefaultAdminMix } from "../util/users.ts"; +import { UserMix } from "../util/users.ts"; export const options = { ...getDefaultOptions(), @@ -12,10 +13,45 @@ const serviceProviderName = "School-SH"; export default wrapTestFunction(main); -function main(users = getDefaultAdminMix()) { +function main(users = new UserMix({ LEHR: 1000 })) { const { providers } = login(users.getLogin()); - const target = providers.find((p) => p.name == serviceProviderName); - if (!target) fail(`could not find sp ${serviceProviderName}`); - loadPage(target.url); - sleep(1); + + const response = group("follow link", () => { + const target = providers.find((p) => p.name == serviceProviderName); + if (!target) fail(`could not find sp ${serviceProviderName}`); + return loadPage(target.url); + }); + + group("finish saml", () => { + let submissionResponse = response.submitForm(); + check(submissionResponse, { + "post to kc succeeded": (r) => r.status == 200, + }); + submissionResponse = submissionResponse.submitForm(); + check(submissionResponse, { + "post to school-sh succeeded": (r) => r.status == 200, + }); + + const stringifiedBody = submissionResponse.body?.toString(); + if (!stringifiedBody) { + fail("no body returned"); + } + const matches = RegExp(/URL='(\S+)'/).exec(stringifiedBody); + const redirectUrl = matches?.at(matches?.length - 1) ?? undefined; + const redirectUrlFound = check(redirectUrl, { + "redirect url found": (url) => url != undefined, + }); + if (!redirectUrlFound) fail("redirect url not found"); + const redirectResponse = get(redirectUrl!, { + headers: { + referer: submissionResponse.url, + }, + tags: { name: "redirect to school-sh login" }, + }); + check(redirectResponse, { + "reached school-sh login": (r) => { + return r.html().find("#loginBox").text().includes("Anmeldung"); + }, + }); + }); } diff --git a/loadtest/usecases/3_change-password.ts b/loadtest/usecases/3_change-password.ts new file mode 100644 index 0000000..9c327a0 --- /dev/null +++ b/loadtest/usecases/3_change-password.ts @@ -0,0 +1,63 @@ +import { check, fail, group } from "k6"; +import { ProfilePage } from "../pages/profile.ts"; +import { getResetPassword } from "../util/api.ts"; +import { defaultHttpCheck, defaultTimingCheck } from "../util/checks.ts"; +import { getDefaultOptions } from "../util/config.ts"; +import { login } from "../util/page.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { getDefaultUserMix } from "../util/users.ts"; + +const users = getDefaultUserMix(); +export const options = { + ...getDefaultOptions(), +}; + +export default wrapTestFunction(main); + +function main() { + const loginData = users.getLogin(); + + const loginResponse = login(loginData); + const { personId } = loginResponse.loginInfo; + if (!personId) fail("no personId"); + + const profilePage = new ProfilePage(personId); + group("go to profile", () => { + profilePage.navigate(); + }); + + const reenterPasswordForm = group("redirect to keycloak", () => { + const response = getResetPassword([ + `redirectUrl=${profilePage.url}`, + `login_hint=${loginData.username}`, + ]); + check(response, { + ...defaultHttpCheck, + }); + return response; + }); + + group("confirm and change password", () => { + const passwordChangeForm = reenterPasswordForm.submitForm({ + fields: { + password: loginData.password, + credentialId: "", + }, + }); + check(passwordChangeForm, { + "password confirmation succeeded": (r) => r.status == 200, + ...defaultTimingCheck, + }); + const response = passwordChangeForm.submitForm({ + fields: { + username: loginData.username, + password: "", + "password-new": loginData.password, + "password-confirm": loginData.password, + }, + }); + check(response, { + ...defaultHttpCheck, + }); + }); +} diff --git a/loadtest/usecases/3_create-class.ts b/loadtest/usecases/3_create-class.ts new file mode 100644 index 0000000..8cc1592 --- /dev/null +++ b/loadtest/usecases/3_create-class.ts @@ -0,0 +1,70 @@ +import { check, group } from "k6"; +import { + OrganisationResponse, + OrganisationsTyp, +} from "../api-client/generated/index.ts"; +import { logout } from "../pages/index.ts"; +import { userListPage } from "../pages/user-list.ts"; +import { + getPersonenkontextWorkflowStep, + postOrganisationen, +} from "../util/api.ts"; +import { defaultTimingCheck, getStatusChecker } from "../util/checks.ts"; +import { getDefaultOptions } from "../util/config.ts"; +import { getRandomString } from "../util/data.ts"; +import { login } from "../util/page.ts"; +import { + createAndFindTestOrg, + deleteAllTestKlassen, +} from "../util/resource-helper.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { UserMix } from "../util/users.ts"; + +const users = new UserMix({ SYSADMIN: 1000 }); +type TestData = { + testOrg: OrganisationResponse; +}; + +export const options = { + ...getDefaultOptions(), +}; + +export function setup(): TestData { + login(users.getLogin()); + const testOrg = createAndFindTestOrg(); + logout(); + return { testOrg }; +} + +export default wrapTestFunction(main); + +export function teardown() { + login(users.getLogin()); + deleteAllTestKlassen(); + logout(); +} + +function main({ testOrg }: TestData) { + group("go to user list", () => { + login(users.getLogin()); + userListPage.fetchData(); + }); + + group("create klasse", () => { + getPersonenkontextWorkflowStep([`limit=25`]); + const bodyParams = { + administriertVon: testOrg.id, + kennung: "", + kuerzel: "", + name: getRandomString(8), + namensergaenzung: "", + typ: OrganisationsTyp.Klasse, + zugehoerigZu: testOrg.id, + }; + const res = postOrganisationen(bodyParams); + check(res, { + "klasse created": getStatusChecker(201), + ...defaultTimingCheck, + }); + }); +} diff --git a/loadtest/usecases/3_lock_unlock_user.ts b/loadtest/usecases/3_lock_unlock_user.ts new file mode 100644 index 0000000..3ad02ab --- /dev/null +++ b/loadtest/usecases/3_lock_unlock_user.ts @@ -0,0 +1,70 @@ +import { check, fail, group } from "k6"; +import { vu } from "k6/execution"; +import { DBiamPersonenuebersichtResponse } from "../api-client/generated/index.ts"; +import { logout } from "../pages/index.ts"; +import { UserDetailsPage } from "../pages/user-details.ts"; +import { userListPage } from "../pages/user-list.ts"; +import { putPersonLock } from "../util/api.ts"; +import { defaultTimingCheck, getStatusChecker } from "../util/checks.ts"; +import { getDefaultOptions, MAX_VUS } from "../util/config.ts"; +import { login } from "../util/page.ts"; +import { + createTestUsers, + deleteAllTestUsers, +} from "../util/resource-helper.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { UserMix } from "../util/users.ts"; + +const users = new UserMix({ SYSADMIN: 1000 }); +type TestData = { + testIds: Array; +}; + +export const options = { + ...getDefaultOptions(), +}; + +export function setup(): TestData { + login(users.getLogin()); + deleteAllTestUsers(); + const testIds: Array = createTestUsers(MAX_VUS); + logout(); + return { testIds }; +} + +export default wrapTestFunction(main); + +export function teardown() { + login(users.getLogin()); + deleteAllTestUsers(); + logout(); +} + +function main({ testIds }: TestData) { + let personenuebersichten: DBiamPersonenuebersichtResponse[] | null = null; + const viewedPersonId = testIds[vu.idInTest % testIds.length]; + group("go to user list", () => { + login(users.getLogin()); + personenuebersichten = userListPage.fetchData().personenuebersichten; + if (!personenuebersichten) fail("did not find personenuebersichten"); + }); + + group("go to user details", () => { + new UserDetailsPage(viewedPersonId).fetchData(); + }); + + group("lock", () => { + lockAndCheck(viewedPersonId, true); + }); + group("unlock", () => { + lockAndCheck(viewedPersonId, false); + }); +} + +function lockAndCheck(personId: string, lock: boolean) { + const response = putPersonLock(personId, lock); + check(response, { + "got expected status": getStatusChecker(202), + ...defaultTimingCheck, + }); +} diff --git a/loadtest/usecases/3_reset-password.ts b/loadtest/usecases/3_reset-password.ts new file mode 100644 index 0000000..d600d65 --- /dev/null +++ b/loadtest/usecases/3_reset-password.ts @@ -0,0 +1,63 @@ +import { check, fail, group } from "k6"; +import { vu } from "k6/execution"; +import { DBiamPersonenuebersichtResponse } from "../api-client/generated/index.ts"; +import { logout } from "../pages/index.ts"; +import { UserDetailsPage } from "../pages/user-details.ts"; +import { userListPage } from "../pages/user-list.ts"; +import { resetPassword } from "../util/api.ts"; +import { defaultTimingCheck, getStatusChecker } from "../util/checks.ts"; +import { getDefaultOptions, MAX_VUS } from "../util/config.ts"; +import { login } from "../util/page.ts"; +import { + createTestUsers, + deleteAllTestUsers, +} from "../util/resource-helper.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { UserMix } from "../util/users.ts"; + +const users = new UserMix({ SYSADMIN: 1000 }); +type TestData = { + testIds: Array; +}; + +export const options = { + ...getDefaultOptions(), +}; + +export function setup(): TestData { + login(users.getLogin()); + deleteAllTestUsers(); + const testIds: Array = createTestUsers(MAX_VUS); + logout(); + return { testIds }; +} + +export default wrapTestFunction(main); + +export function teardown() { + login(users.getLogin()); + deleteAllTestUsers(); + logout(); +} + +function main({ testIds }: TestData) { + let personenuebersichten: DBiamPersonenuebersichtResponse[] | null = null; + const viewedPersonId = testIds[vu.idInTest % testIds.length]; + group("go to user list", () => { + login(users.getLogin()); + personenuebersichten = userListPage.fetchData().personenuebersichten; + if (!personenuebersichten) fail("did not find personenuebersichten"); + }); + + group("go to user details", () => { + new UserDetailsPage(viewedPersonId).fetchData(); + }); + + group("reset password", () => { + const response = resetPassword(viewedPersonId); + check(response, { + "got expected status": getStatusChecker(202), + ...defaultTimingCheck, + }); + }); +} diff --git a/loadtest/usecases/3_show-edit-user.ts b/loadtest/usecases/3_show-edit-user.ts new file mode 100644 index 0000000..ea2fa94 --- /dev/null +++ b/loadtest/usecases/3_show-edit-user.ts @@ -0,0 +1,24 @@ +import { UserDetailsPage } from "../pages/user-details.ts"; +import { userListPage } from "../pages/user-list.ts"; +import { getDefaultOptions } from "../util/config.ts"; +import { pickRandomItem } from "../util/data.ts"; +import { login } from "../util/page.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { getDefaultAdminMix } from "../util/users.ts"; + +const users = getDefaultAdminMix(); + +export const options = { + ...getDefaultOptions(), +}; + +export default wrapTestFunction(main); + +function main() { + // go to user list + login(users.getLogin()); + const { personenuebersichten } = userListPage.fetchData(); + + const viewedPersonId = pickRandomItem(personenuebersichten).personId; + new UserDetailsPage(viewedPersonId).fetchData(); +} diff --git a/loadtest/usecases/3_show-role-list.ts b/loadtest/usecases/3_show-role-list.ts new file mode 100644 index 0000000..ed5e4df --- /dev/null +++ b/loadtest/usecases/3_show-role-list.ts @@ -0,0 +1,23 @@ +import { group } from "k6"; +import { getLoginInfo, getRollen } from "../util/api.ts"; +import { getDefaultOptions } from "../util/config.ts"; +import { goToUserList } from "../util/page.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; +import { UserMix } from "../util/users.ts"; + +const users = new UserMix({ SYSADMIN: 1 }); + +export const options = { + ...getDefaultOptions(), +}; + +export default wrapTestFunction(main); + +function main() { + goToUserList(users.getLogin()); + getLoginInfo(); + group("list rollen", () => { + for (let offset = 0; offset < 5; offset++) + getRollen([`offset=${offset}`, "limit=30", "searchStr="]); + }); +} diff --git a/loadtest/usecases/__template.ts b/loadtest/usecases/__template.ts index 7e19666..fdb198d 100644 --- a/loadtest/usecases/__template.ts +++ b/loadtest/usecases/__template.ts @@ -1,8 +1,11 @@ import { getDefaultOptions } from "../util/config.ts"; +import { wrapTestFunction } from "../util/usecase-wrapper.ts"; import { getDefaultUserMix } from "../util/users.ts"; export const options = { ...getDefaultOptions(), }; -export default function main(users = getDefaultUserMix()) {} +export default wrapTestFunction(main); + +function main(users = getDefaultUserMix()) {} diff --git a/loadtest/util/api.ts b/loadtest/util/api.ts index 7bf9325..e0e8a71 100644 --- a/loadtest/util/api.ts +++ b/loadtest/util/api.ts @@ -1,14 +1,33 @@ import { check, fail } from "k6"; -import http, { RefinedParams, RequestBody, ResponseType } from "k6/http"; + +import { + del, + get, + patch, + put, + RefinedParams, + request, + RequestBody, + ResponseType, + url, +} from "k6/http"; import { + CreateOrganisationBodyParams, DbiamCreatePersonWithPersonenkontexteBodyParams, DBiamPersonenuebersichtControllerFindPersonenuebersichten200Response, + DBiamPersonenuebersichtResponse, DBiamPersonResponse, FindRollenResponse, + LockUserBodyParams, OrganisationResponse, + ParentOrganisationenResponse, + PersonendatensatzResponse, PersonenkontextWorkflowResponse, PersonFrontendControllerFindPersons200Response, + PersonInfoResponse, ServiceProviderResponse, + TokenRequiredResponse, + TokenStateResponse, UserinfoResponse, } from "../api-client/generated/index.ts"; import { @@ -17,12 +36,13 @@ import { getStatusChecker, } from "./checks.ts"; import { getBackendUrl } from "./config.ts"; +import { getFutureDate, NAME_PREFIX } from "./data.ts"; import { prettyLog } from "./debug.ts"; const backendUrl = getBackendUrl(); export function makeQueryString(pairs: Array): string { - return "?".concat(pairs.join("&")); + return "?".concat(pairs.map(encodeURI).join("&")); } /** * Removes querystring from url. Returns unchanged string, if no query is present @@ -34,7 +54,7 @@ export function removeQueryString(url: string): string { } export function makeHttpRequest( - verb: "get" | "post", + verb: "get" | "post" | "put" | "delete", resource: string, options?: Partial<{ query: Array; @@ -44,13 +64,27 @@ export function makeHttpRequest( ) { const queryString = options?.query ? makeQueryString(options.query) : ""; const url = `${backendUrl}${resource}${queryString}`; - return http.request(verb.toUpperCase(), url, options?.body, { + const response = request(verb.toUpperCase(), url, options?.body, { ...options?.params, tags: { name: `${backendUrl}${resource}`, resource, }, }); + if (response.error || response.error_code) + prettyLog( + { + url: response.url, + status: response.status, + statusText: response.status_text, + headers: response.headers, + timings: response.timings, + error: response.error, + errorCode: response.error_code, + }, + "HTTP ERROR", + ); + return response; } export function getLogin(query: Array) { @@ -59,6 +93,12 @@ export function getLogin(query: Array) { return response; } +export function getLogout() { + const response = makeHttpRequest("get", "auth/logout"); + check(response, defaultHttpCheck); + return response; +} + export function getLoginInfo() { const response = makeHttpRequest("get", "auth/logininfo"); check(response, defaultHttpCheck); @@ -79,10 +119,9 @@ export function getServiceProviders() { export function getServiceProviderLogos(providers: Array<{ id: string }>) { try { for (const provider of providers) { - const logoResponse = http.get( - http.url`${backendUrl}provider/${provider.id}/logo`, - { tags: { resource: "provider/${id}/logo" } }, - ); + const logoResponse = get(url`${backendUrl}provider/${provider.id}/logo`, { + tags: { resource: "provider/${id}/logo" }, + }); check(logoResponse, { "got provider logos": (r) => r.status === 200, ...defaultTimingCheck, @@ -105,14 +144,51 @@ export function getAdministeredOrganisationenById( query?: Array, ) { const queryString = query ? makeQueryString(query) : ""; - const response = http.get( - http.url`${backendUrl}organisationen/${id}/administriert${queryString}`, + const response = get( + url`${backendUrl}organisationen/${id}/administriert${queryString}`, { tags: { resource: "organisationen/${id}/administriert" } }, ); check(response, defaultHttpCheck); return response.json() as unknown as Array; } +export function getParentOrganisationenByIds(organisationIds: Array) { + const body = JSON.stringify({ + organisationIds, + }); + const params = { + headers: { "Content-Type": "application/json" }, + }; + const response = makeHttpRequest("post", "organisationen/parents-by-ids", { + body, + params, + }); + check(response, { + "got expected status": getStatusChecker([200, 201]), + ...defaultTimingCheck, + }); + return response.json() as unknown as ParentOrganisationenResponse; +} + +export function postOrganisationen(bodyParams: CreateOrganisationBodyParams) { + const body = JSON.stringify(bodyParams); + const params = { + headers: { "Content-Type": "application/json" }, + }; + const response = makeHttpRequest("post", "organisationen", { + body, + params, + }); + return response; +} + +export function deleteKlasse(id: string) { + const response = del(url`${backendUrl}/organisationen/${id}/klasse`, null, { + tags: { resource: "organisationen/${id}/klasse" }, + }); + return response; +} + export function getPersonenIds( personen?: PersonFrontendControllerFindPersons200Response, ): Set { @@ -126,6 +202,27 @@ export function getPersonen(query?: Array) { return response.json() as unknown as PersonFrontendControllerFindPersons200Response; } +export function getPersonById(id: string, query?: Array) { + const response = makeHttpRequest("get", `personen/${id}`, { query }); + check(response, defaultHttpCheck); + return response.json() as unknown as PersonendatensatzResponse; +} + +export function getPersonInfo(query?: Array) { + const response = makeHttpRequest("get", `person-info`, { query }); + check(response, defaultHttpCheck); + return response.json() as unknown as PersonInfoResponse; +} + +export function deletePersonById(id: string) { + const response = makeHttpRequest("delete", `personen/${id}`); + check(response, { + "got expected status": getStatusChecker(204), + ...defaultTimingCheck, + }); + return response; +} + export function getPersonenUebersicht(personIds: Set) { const body = JSON.stringify({ personIds: Array.from(personIds), @@ -146,7 +243,16 @@ export function getPersonenUebersicht(personIds: Set) { ) as unknown as DBiamPersonenuebersichtControllerFindPersonenuebersichten200Response["items"]; } -export function getRollen(query?: Array) { +export function getPersonenUebersichtById(personId: string) { + const response = makeHttpRequest( + "get", + `dbiam/personenuebersicht/${personId}`, + ); + check(response, defaultHttpCheck); + return response.json() as unknown as DBiamPersonenuebersichtResponse; +} + +export function getRollenAsAdmin(query?: Array) { const response = makeHttpRequest("get", "person-administration/rollen", { query, }); @@ -156,6 +262,14 @@ export function getRollen(query?: Array) { ) as unknown as FindRollenResponse["moeglicheRollen"]; } +export function getRollen(query: Array) { + const response = makeHttpRequest("get", "person-administration/rollen", { + query, + }); + check(response, defaultHttpCheck); + return response.json() as unknown as FindRollenResponse; +} + export function getPersonenkontextWorkflowStep(query?: Array) { const response = makeHttpRequest("get", "personenkontext-workflow/step", { query, @@ -180,3 +294,49 @@ export function postPersonenkontextWorkflow( }); return response.json() as unknown as DBiamPersonResponse; } + +export function getTwoFactorRequired(query: Array) { + const response = makeHttpRequest("get", "2fa-token/required", { + query, + }); + check(response, defaultHttpCheck); + return response.json() as unknown as TokenRequiredResponse; +} + +export function getTwoFactorState(query: Array) { + const response = makeHttpRequest("get", "2fa-token/state", { + query, + }); + check(response, defaultHttpCheck); + return response.json() as unknown as TokenStateResponse; +} + +export function resetPassword(personId: string) { + const response = patch(url`${backendUrl}personen/${personId}/password`); + return response; +} + +export function getResetPassword(query: Array) { + const response = makeHttpRequest("get", "auth/reset-password", { query }); + return response; +} + +export function putPersonLock(personId: string, lock: boolean) { + // TODO: befristung + const lockUserBodyParams: LockUserBodyParams = { + lock, + //@ts-expect-error openapi generator converts this to camelcase + locked_by: NAME_PREFIX, + locked_until: getFutureDate(), + }; + const params = { + headers: { "Content-Type": "application/json" }, + }; + const body = JSON.stringify(lockUserBodyParams); + const response = put( + `${backendUrl}personen/${personId}/lock-user`, + body, + params, + ); + return response; +} diff --git a/loadtest/util/config.ts b/loadtest/util/config.ts index d0f9782..bae02b0 100644 --- a/loadtest/util/config.ts +++ b/loadtest/util/config.ts @@ -1,5 +1,5 @@ const SPSH_BASE = __ENV["SPSH_BASE"]; -const MAX_VUS = Number.parseInt(__ENV["MAX_VUS"]); +export const MAX_VUS = Number.parseInt(__ENV["MAX_VUS"]); export enum CONFIG { SPIKE = "spike", @@ -34,6 +34,7 @@ export function getDefaultOptions() { { duration: "1m", target: Math.round(maxVUs * 0.5) }, // ramp up 1 { duration: "1m", target: Math.round(maxVUs * 0.8) }, // ramp up 2 { duration: "1m", target: maxVUs }, // ramp up 3 + { duration: "30s", target: maxVUs }, // hold { duration: "1m", target: Math.round(maxVUs * 0.8) }, // ramp down 1 { duration: "1m", target: Math.round(maxVUs * 0.5) }, // ramp down 2 { duration: "1m", target: 0 }, // ramp down 3 diff --git a/loadtest/util/data.ts b/loadtest/util/data.ts index 6bc20ea..6b24f7e 100644 --- a/loadtest/util/data.ts +++ b/loadtest/util/data.ts @@ -1,4 +1,7 @@ import { vu } from "k6/execution"; + +export const NAME_PREFIX = "PLT"; + export function pickRandomItem(array: Array): T { return array[Math.floor(Math.random() * array.length)]; } @@ -11,6 +14,7 @@ export function getRandomPersNummer(): string { } function mapNumberIntoAlphabet(n: number): string { + n = Math.round(n); if (n == 0) return "a"; const alphabet = "abcdefghijklmnopqrstuvwxyz"; let s = ""; @@ -21,13 +25,30 @@ function mapNumberIntoAlphabet(n: number): string { return s; } +export function getRandomString(length: number) { + let s = ""; + while (length--) { + const n = Math.floor(Math.random() * 26); + const char = mapNumberIntoAlphabet(n); + s = s.concat(char); + } + return s; +} + export function getRandomName(): { familienname: string; vorname: string; } { - const s = `PLT-${mapNumberIntoAlphabet(vu.idInTest)}-${mapNumberIntoAlphabet(vu.iterationInScenario)}`; + const vuId = mapNumberIntoAlphabet(vu.idInTest); + const iteration = mapNumberIntoAlphabet(vu.iterationInScenario); + const random = getRandomString(8); + const s = [NAME_PREFIX, vuId, iteration, random].join("-"); return { familienname: s, vorname: s, }; } + +export function getFutureDate() { + return new Date("2055-07-31T22:00:00.000Z"); +} diff --git a/loadtest/util/load-linked-resources.ts b/loadtest/util/load-linked-resources.ts index 7a8f830..49b64c2 100644 --- a/loadtest/util/load-linked-resources.ts +++ b/loadtest/util/load-linked-resources.ts @@ -1,5 +1,5 @@ import { Selection } from "k6/html"; -import http, { RefinedResponse, ResponseType } from "k6/http"; +import { get, RefinedResponse, ResponseType } from "k6/http"; import { Counter, Trend } from "k6/metrics"; // Metrics @@ -35,7 +35,7 @@ function loadLinkedResources( const urls = links.map((link: string) => `${baseUrl}${link}`); responses = responses.concat( urls.map((url) => - http.get(url, { tags: { name: `Linked Resource from ${baseUrl}` } }), + get(url, { tags: { name: `Linked Resource from ${baseUrl}` } }), ), ); } diff --git a/loadtest/util/page.ts b/loadtest/util/page.ts index 3c992b2..bb3d324 100644 --- a/loadtest/util/page.ts +++ b/loadtest/util/page.ts @@ -43,4 +43,5 @@ export function loadPage(url: string, name?: string) { ...defaultTimingCheck, }); loadLinkedResourcesAndCheck(response); + return response; } diff --git a/loadtest/util/resource-helper.ts b/loadtest/util/resource-helper.ts new file mode 100644 index 0000000..811ee4c --- /dev/null +++ b/loadtest/util/resource-helper.ts @@ -0,0 +1,148 @@ +import { fail } from "k6"; +import { batch, BatchRequest } from "k6/http"; +import { + CreateOrganisationBodyParams, + DbiamCreatePersonWithPersonenkontexteBodyParams, + DBiamPersonResponse, + OrganisationsTyp, + RollenArt, +} from "../api-client/generated/index.ts"; +import { + getAdministeredOrganisationenById, + getOrganisationen, + getPersonen, + getRollen, + makeHttpRequest, +} from "./api.ts"; +import { getBackendUrl } from "./config.ts"; +import { getRandomName, NAME_PREFIX } from "./data.ts"; + +const orgParams: CreateOrganisationBodyParams = { + name: `${NAME_PREFIX}-Test-Schule`, + kennung: "1234567", + typ: OrganisationsTyp.Schule, +}; + +export function createTestUsers(n: number): Array { + const creationStartTime = Date.now(); + console.log(`creating ${n} users`); + const testOrg = createAndFindTestOrg(); + + const rollen = getRollen(["rolleName="]); + const rolle = rollen.moeglicheRollen.find( + (r) => r.rollenart === RollenArt.Lern, + ); + if (!rolle) fail("setup: no suitable rolle available for user creation"); + + const requests: Array = []; + const befristung = new Date(Date.now() + 1000 * 60 * 60 * 4); + for (let index = 0; index < n; index++) { + const creationParams: DbiamCreatePersonWithPersonenkontexteBodyParams = { + ...getRandomName(), + befristung: befristung, + createPersonenkontexte: [ + { + organisationId: testOrg.id, + rolleId: rolle.id, + }, + ], + }; + const body = JSON.stringify(creationParams); + requests.push({ + method: "POST", + url: `${getBackendUrl()}personenkontext-workflow`, + body, + params: { + headers: { "Content-Type": "application/json" }, + }, + }); + } + const responses = batch(requests); + console.log("done creating users"); + console.log(`took ${Date.now() - creationStartTime}ms`); + try { + const ids = responses + .map((r) => r.json() as unknown as DBiamPersonResponse) + .map((p) => p.person.id); + return ids; + } catch { + fail("setup: creating persons failed"); + } +} + +export function createAndFindTestOrg() { + let organisation = findTestOrg(); + if (!organisation) createTestOrganisation(); + organisation = findTestOrg(); + if (!organisation) + fail("setup: test organisation could not be created/found"); + return organisation; +} + +function findTestOrg() { + const organisationen = getOrganisationen([`name=${orgParams.name}`]); + return organisationen.find( + (o) => o.name === orgParams.name && o.typ === orgParams.typ, + ); +} + +function createTestOrganisation() { + const body = JSON.stringify(orgParams); + const params = { + headers: { + "Content-Type": "application/json", + }, + }; + const response = makeHttpRequest("post", "organisationen", { + body, + params, + }); + return response; +} + +export function deleteTestUsers(ids: Array) { + try { + batch( + ids.map((id) => ({ + method: "DELETE", + url: `${getBackendUrl()}personen/${id}`, + })), + ); + } catch { + fail("teardown: deleting persons failed"); + } +} + +export function deleteAllTestUsers() { + const query = ["offset=0", "limit=100", `suchFilter=${NAME_PREFIX}-`]; + let users = getPersonen(query); + console.log(`deleting ${users.total} test users`); + while (users.total) { + deleteTestUsers(users.items.map((p) => p.person.id)); + users = getPersonen(query); + } +} + +export function deleteAllTestKlassen() { + const org = createAndFindTestOrg(); + const query = ["offset=0", "limit=100"]; + let klassen = getAdministeredOrganisationenById(org.id, query); + console.log(`deleting ${klassen.length} test klassen`); + while (klassen.length) { + deleteTestKlassen(klassen.map((p) => p.id)); + klassen = getAdministeredOrganisationenById(org.id, query); + } +} + +function deleteTestKlassen(ids: Array) { + try { + batch( + ids.map((id) => ({ + method: "DELETE", + url: `${getBackendUrl()}organisationen/${id}/klasse`, + })), + ); + } catch { + fail("teardown: deleting klassen failed"); + } +} diff --git a/package.json b/package.json index 2f056ec..97ce8be 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "format": "prettier --write .", "lint": "eslint --fix", "typecheck": "tsc", - "generate-client": "openapi-generator-cli generate -g typescript -i loadtest/api-client/openapispec.json -o loadtest/api-client/generated --openapi-normalizer REFACTOR_ALLOF_WITH_PROPERTIES_ONLY=true" + "generate-client": "openapi-generator-cli generate -g typescript -i loadtest/api-client/openapispec.json -o loadtest/api-client/generated --openapi-normalizer REFACTOR_ALLOF_WITH_PROPERTIES_ONLY=true -p importFileExtension=.ts" }, "keywords": [], "author": "",