From b3a01e8798867bb10dc8d7705ac88c9535507f24 Mon Sep 17 00:00:00 2001 From: Dagfinn Olsen Date: Thu, 31 Oct 2024 11:14:56 +0100 Subject: [PATCH] test(load-test): Performance tests for create dialog and search (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description See commits for details ## Related Issue(s) - #1326 ## Verification - [ ] **Your** code builds clean without any errors or warnings - [x] Manual testing done (required) - [ ] Relevant automated test added (if you find this hard, leave it and we'll help out) ## Documentation - [ ] Documentation is updated (either in `docs`-directory, Altinnpedia or a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if applicable) ## Summary by CodeRabbit - **New Features** - Introduced new GitHub Actions workflows for K6 performance testing, allowing manual execution with customizable parameters. - Added performance testing scripts for end-user interactions and dialog creation using the K6 framework. - **Bug Fixes** - Improved handling of authorization headers in request parameter functions. - **Chores** - Updated `.gitignore` to exclude sensitive files and generated token files. - Added a script for generating tokens for service owners and end users. --------- Co-authored-by: Ole Jørgen Skogstad Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/dispatch-k6-performance.yml | 60 ++++++++++++ .../workflow-run-k6-performance.yml | 55 +++++++++++ .gitignore | 7 ++ tests/k6/common/request.js | 7 +- .../enduser/performance/simple-search.js | 57 +++++++++++ .../performancetest_data/endusers-staging.csv | 2 + .../serviceowners-staging.csv | 2 + tests/k6/tests/scripts/generate_tokens.sh | 94 +++++++++++++++++++ .../serviceowner/performance/create-dialog.js | 49 ++++++++++ .../performance/createremove-no-delay.js | 2 +- .../serviceowner/testdata/01-create-dialog.js | 4 +- 11 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/dispatch-k6-performance.yml create mode 100644 .github/workflows/performance-workflows/workflow-run-k6-performance.yml create mode 100644 tests/k6/tests/enduser/performance/simple-search.js create mode 100644 tests/k6/tests/performancetest_data/endusers-staging.csv create mode 100644 tests/k6/tests/performancetest_data/serviceowners-staging.csv create mode 100755 tests/k6/tests/scripts/generate_tokens.sh create mode 100644 tests/k6/tests/serviceowner/performance/create-dialog.js diff --git a/.github/workflows/dispatch-k6-performance.yml b/.github/workflows/dispatch-k6-performance.yml new file mode 100644 index 000000000..03f02c471 --- /dev/null +++ b/.github/workflows/dispatch-k6-performance.yml @@ -0,0 +1,60 @@ +name: Run K6 performance test + +on: + workflow_dispatch: + inputs: + apiVersion: + description: 'API Version' + required: true + default: 'v1' + environment: + description: 'Environment' + required: true + default: 'staging' + type: choice + options: + - test + - staging + - performance + tokens: + description: 'Tokens to generate; for create dialog, search, none, or both' + required: true + default: 'both' + type: choice + options: + - both + - enterprise + - personal + - none + vus: + description: 'Number of VUS' + required: true + type: number + default: 10 + duration: + description: 'Duration of test, ie 30s, 1m, 10m' + required: true + default: 1m + type: string + testSuitePath: + description: 'Path to test suite to run' + required: true + default: 'tests/k6/tests/serviceowner/performance/create-dialog.js' + +jobs: + k6-performance: + name: "Run K6 performance test" + uses: ./.github/workflows/performance-workflows/workflow-run-k6-performance.yml + secrets: + TOKEN_GENERATOR_USERNAME: ${{ secrets.TOKEN_GENERATOR_USERNAME }} + TOKEN_GENERATOR_PASSWORD: ${{ secrets.TOKEN_GENERATOR_PASSWORD }} + K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }} + K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }} + with: + environment: ${{ inputs.environment }} + apiVersion: ${{ inputs.apiVersion }} + testSuitePath: ${{ inputs.testSuitePath }} + vus: ${{ inputs.vus }} + duration: ${{ inputs.duration }} + tokens: ${{ inputs.tokens }} + diff --git a/.github/workflows/performance-workflows/workflow-run-k6-performance.yml b/.github/workflows/performance-workflows/workflow-run-k6-performance.yml new file mode 100644 index 000000000..bd55f70cd --- /dev/null +++ b/.github/workflows/performance-workflows/workflow-run-k6-performance.yml @@ -0,0 +1,55 @@ +name: Run K6 performance tests + +on: + workflow_call: + inputs: + apiVersion: + required: true + type: string + environment: + required: true + type: string + testSuitePath: + required: true + type: string + vus: + required: true + type: number + duration: + required: true + type: string + tokens: + required: true + type: string + secrets: + TOKEN_GENERATOR_USERNAME: + required: true + TOKEN_GENERATOR_PASSWORD: + required: true + K6_CLOUD_TOKEN: + required: true + K6_CLOUD_PROJECT_ID: + required: true + +jobs: + k6-test: + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup k6 + uses: grafana/setup-k6-action@v1 + - name: Run K6 tests (${{ inputs.testSuitePath }}) + run: | + ./tests/k6/tests/scripts/generate_tokens.sh ./tests/k6/tests/performancetest_data ${{ inputs.tokens }} + k6 run ${{ inputs.testSuitePath }} --quiet --log-output=stdout --include-system-env-vars --vus=${{ inputs.vus }} --duration=${{ inputs.duration }} + env: + API_ENVIRONMENT: ${{ inputs.environment }} + API_VERSION: ${{ inputs.apiVersion }} + TOKEN_GENERATOR_USERNAME: ${{ secrets.TOKEN_GENERATOR_USERNAME }} + TOKEN_GENERATOR_PASSWORD: ${{ secrets.TOKEN_GENERATOR_PASSWORD }} + K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }} + K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }} diff --git a/.gitignore b/.gitignore index 8b4c08a26..bedd35f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -374,3 +374,10 @@ FodyWeavers.xsd # MacOS .DS_Store + +# Secrets file used by act +.secrets + +# Generated files with tokens +**/.endusers-with-tokens.csv +**/.serviceowners-with-tokens.csv diff --git a/tests/k6/common/request.js b/tests/k6/common/request.js index 80bb43125..cc2be8457 100644 --- a/tests/k6/common/request.js +++ b/tests/k6/common/request.js @@ -18,7 +18,7 @@ function resolveParams(defaultParams, params) { function getServiceOwnerRequestParams(params = null, tokenOptions = null) { params = params || {}; - const headers = params.Headers || {}; + const headers = params.headers || {}; const hasOverridenAuthorizationHeader = headers.Authorization !== undefined; const defaultParams = { @@ -33,11 +33,14 @@ function getServiceOwnerRequestParams(params = null, tokenOptions = null) { } function getEnduserRequestParams(params = null, tokenOptions = null) { + params = params || {}; + const headers = params.headers || {}; + const hasOverridenAuthorizationHeader = headers.Authorization !== undefined; let defaultParams = { headers: { 'Accept': 'application/json', 'User-Agent': 'dialogporten-k6', - 'Authorization': 'Bearer ' + getEnduserTokenFromGenerator(tokenOptions) + 'Authorization': hasOverridenAuthorizationHeader ? headers.Authorization : 'Bearer ' + getEnduserTokenFromGenerator(tokenOptions) } } diff --git a/tests/k6/tests/enduser/performance/simple-search.js b/tests/k6/tests/enduser/performance/simple-search.js new file mode 100644 index 000000000..be99517b2 --- /dev/null +++ b/tests/k6/tests/enduser/performance/simple-search.js @@ -0,0 +1,57 @@ +import { getEU, expect, expectStatusFor, describe } from "../../../common/testimports.js"; +import { SharedArray } from 'k6/data'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const filenameEndusers = '../../performancetest_data/.endusers-with-tokens.csv'; + +const endUsers = new SharedArray('endUsers', function () { + try { + const csvData = papaparse.parse(open(filenameEndusers), { header: true, skipEmptyLines: true }).data; + if (!csvData.length) { + throw new Error('No data found in CSV file'); + } + csvData.forEach((user, index) => { + if (!user.token || !user.ssn) { + throw new Error(`Missing required fields at row ${index + 1}`); + } + }); + return csvData; + } catch (error) { + throw new Error(`Failed to load end users: ${error.message}`); + } +}); + +export let options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.5)', 'p(99.9)', 'count'], + thresholds: { + 'http_req_duration{name:simple search}': [], + 'http_reqs{name:simple search}': [], + }, +}; + +export default function() { + if ((options.vus === undefined || options.vus === 1) && (options.iterations === undefined || options.iterations === 1)) { + simpleSearch(endUsers[0]); + } + else { + simpleSearch(randomItem(endUsers)); + } +} + +export function simpleSearch(enduser) { + let paramsWithToken = { + headers: { + Authorization: "Bearer " + enduser.token + }, + tags: { name: 'simple search' } + } + let defaultParty = "urn:altinn:person:identifier-no:" + enduser.ssn; + let defaultFilter = "?Party=" + defaultParty; + describe('Perform simple dialog list', () => { + let r = getEU('dialogs' + defaultFilter, paramsWithToken); + expectStatusFor(r).to.equal(200); + expect(r, 'response').to.have.validJsonBody(); + }); +} + diff --git a/tests/k6/tests/performancetest_data/endusers-staging.csv b/tests/k6/tests/performancetest_data/endusers-staging.csv new file mode 100644 index 000000000..28cf9dcdb --- /dev/null +++ b/tests/k6/tests/performancetest_data/endusers-staging.csv @@ -0,0 +1,2 @@ +ssn,resource,scopes +08895699684,super-simple-service,digdir:dialogporten diff --git a/tests/k6/tests/performancetest_data/serviceowners-staging.csv b/tests/k6/tests/performancetest_data/serviceowners-staging.csv new file mode 100644 index 000000000..449a5e7c6 --- /dev/null +++ b/tests/k6/tests/performancetest_data/serviceowners-staging.csv @@ -0,0 +1,2 @@ +org,orgno,scopes,resource +digdir,991825827,digdir:dialogporten.serviceprovider,super-simple-service diff --git a/tests/k6/tests/scripts/generate_tokens.sh b/tests/k6/tests/scripts/generate_tokens.sh new file mode 100755 index 000000000..06df1ab24 --- /dev/null +++ b/tests/k6/tests/scripts/generate_tokens.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +# Check if required environment variables are set +if [ -z "$TOKEN_GENERATOR_USERNAME" ] || [ -z "$TOKEN_GENERATOR_PASSWORD" ] || [ -z "$API_ENVIRONMENT" ]; then + echo "Error: TOKEN_GENERATOR_USERNAME, TOKEN_GENERATOR_PASSWORD, and API_ENVIRONMENT must be set" + exit 1 +fi + +# Function to display usage information +usage() { + echo "Usage: $0 " + echo " : Path to the test data files" + echo " : Type of tokens to generate (both, enterprise, or personal)" + exit 1 +} + +# Validate arguments +if [ $# -ne 2 ]; then + usage +fi + +tokengenuser=${TOKEN_GENERATOR_USERNAME} +tokengenpasswd=${TOKEN_GENERATOR_PASSWORD} + +env="" +case $API_ENVIRONMENT in + "test") + env="at21" ;; + "staging") + env="tt02" ;; + "performance") + env="yt01" ;; + *) + echo "Error: Unknown api environment $API_ENVIRONMENT" + exit 1 ;; +esac + +testdatafilepath=$1 +tokens=$2 + +# Validate tokens argument +if [[ ! "$tokens" =~ ^(both|enterprise|personal)$ ]]; then + echo "Error: Invalid token type. Must be 'both', 'enterprise', or 'personal'." + usage +fi + +serviceowner_datafile="$testdatafilepath/serviceowners-$API_ENVIRONMENT.csv" +serviceowner_tokenfile="$testdatafilepath/.serviceowners-with-tokens.csv" +enduser_datafile="$testdatafilepath/endusers-$API_ENVIRONMENT.csv" +enduser_tokenfile="$testdatafilepath/.endusers-with-tokens.csv" + +if [ "$tokens" = "both" ] || [ "$tokens" = "enterprise" ]; then + if [ ! -f "$serviceowner_datafile" ]; then + echo "Error: Input file not found: $serviceowner_datafile" + exit 1 + fi + echo "org,orgno,scopes,resource,token" > $serviceowner_tokenfile + while IFS=, read -r org orgno scopes resource + do + url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetEnterpriseToken?org=$org&env=$env&scopes=$scopes&orgno=$orgno&ttl=3600" + token=$(curl -s -f $url -u "$tokengenuser:$tokengenpasswd" ) + if [ $? -ne 0 ]; then + echo "Error: Failed to generate enterprise token for: $env, $org, $orgno, $scopes " + continue + fi + echo "$org,$orgno,$scopes,$resource,$token" >> $serviceowner_tokenfile + status=$? + if [ $status -ne 0 ]; then + echo "Error: Failed to write enterprise token to file for: $env, $org, $orgno, $scopes" + fi + done < <(tail -n +2 $serviceowner_datafile) +fi + +if [ "$tokens" = "both" ] || [ "$tokens" = "personal" ]; then + if [ ! -f "$enduser_datafile" ]; then + echo "Error: Input file not found: $enduser_datafile" + exit 1 + fi + echo "ssn,resource,scopes,token" > $enduser_tokenfile + while IFS=, read -r ssn resource scopes + do + url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetPersonalToken?env=$env&scopes=$scopes&pid=$ssn&ttl=3600" + token=$(curl -s -f $url -u "$tokengenuser:$tokengenpasswd" ) + if [ $? -ne 0 ]; then + echo "Error: Failed to generate personal token for: $ssn, $scopes " + continue + fi + echo "$ssn,$resource,$scopes,$token" >> $enduser_tokenfile + status=$? + if [ $status -ne 0 ]; then + echo "Error: Failed to write personal token to file for: $ssn, $scopes" + fi + done < <(tail -n +2 $enduser_datafile) +fi diff --git a/tests/k6/tests/serviceowner/performance/create-dialog.js b/tests/k6/tests/serviceowner/performance/create-dialog.js new file mode 100644 index 000000000..60aef04e9 --- /dev/null +++ b/tests/k6/tests/serviceowner/performance/create-dialog.js @@ -0,0 +1,49 @@ +import { postSO, expect, describe } from "../../../common/testimports.js"; +import { SharedArray } from 'k6/data'; +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; +import { default as dialogToInsert } from '../testdata/01-create-dialog.js'; +import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +const filenameServiceowners = '../../performancetest_data/.serviceowners-with-tokens.csv'; +const filenameEndusers = `../../performancetest_data/endusers-${__ENV.API_ENVIRONMENT}.csv`; + +const serviceOwners = new SharedArray('serviceOwners', function () { + return papaparse.parse(open(filenameServiceowners), { header: true, skipEmptyLines: true }).data; +}); + +const endUsers = new SharedArray('endUsers', function () { + return papaparse.parse(open(filenameEndusers), { header: true, skipEmptyLines: true }).data; + }); + +export let options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.5)', 'p(99.9)', 'count'], + thresholds: { + 'http_req_duration{scenario:default}': [`max>=0`], + 'http_req_duration{name:create dialog}': [], + 'http_reqs{name:create dialog}': [], + }, +}; + +export default function() { + if ((options.vus === undefined || options.vus === 1) && (options.iterations === undefined || options.iterations === 1)) { + createDialog(serviceOwners[0], endUsers[0]); + } + else { + createDialog(randomItem(serviceOwners), randomItem(endUsers)); + } + } + +export function createDialog(serviceOwner, endUser) { + var paramsWithToken = { + headers: { + Authorization: "Bearer " + serviceOwner.token + }, + tags: { name: 'create dialog' } + } + + describe('create dialog', () => { + let r = postSO('dialogs', dialogToInsert(endUser.ssn), paramsWithToken); + expect(r.status, 'response status').to.equal(201); + }); + +} \ No newline at end of file diff --git a/tests/k6/tests/serviceowner/performance/createremove-no-delay.js b/tests/k6/tests/serviceowner/performance/createremove-no-delay.js index 4dbb6b095..27a3a380f 100644 --- a/tests/k6/tests/serviceowner/performance/createremove-no-delay.js +++ b/tests/k6/tests/serviceowner/performance/createremove-no-delay.js @@ -4,7 +4,7 @@ import { default as dialogToInsert } from '../testdata/01-create-dialog.js'; export function setup() { // Get the token during setup stage so that it doesn't interfere with timings return { - Headers: { + headers: { Authorization: "Bearer " + getServiceOwnerTokenFromGenerator() } } diff --git a/tests/k6/tests/serviceowner/testdata/01-create-dialog.js b/tests/k6/tests/serviceowner/testdata/01-create-dialog.js index 0bbc7b1cd..940ecfba6 100644 --- a/tests/k6/tests/serviceowner/testdata/01-create-dialog.js +++ b/tests/k6/tests/serviceowner/testdata/01-create-dialog.js @@ -2,10 +2,10 @@ import { uuidv4 } from '../../../common/testimports.js' import { getDefaultEnduserSsn } from "../../../common/token.js"; import { sentinelValue } from "../../../common/config.js"; -export default function () { +export default function (endUser = getDefaultEnduserSsn()) { return { "serviceResource": "urn:altinn:resource:ttd-dialogporten-automated-tests", // urn starting with urn:altinn:resource: - "party": "urn:altinn:person:identifier-no:" + getDefaultEnduserSsn(), // or urn:altinn:organization:identifier-no:<9 digits> + "party": "urn:altinn:person:identifier-no:" + endUser, // or urn:altinn:organization:identifier-no:<9 digits> "status": "new", // valid values: new, inprogress, waiting, signing, cancelled, completed "extendedStatus": "urn:any/valid/uri", "dueAt": "2033-11-25T06:37:54.2920190Z", // must be UTC