Files
opencloud/tests/groupware/groupware.ts
Pascal Bleser efa757e4a4 Groupware improvements: refactoring, k6 tests
* refactored the models to be strongly typed with structs and mapstruct
   to decompose the dynamic parts of the JMAP payloads

 * externalized large JSON strings for tests into .json files under
   testdata/

 * added a couple of fantasy Graph groupware APIs to explore further
   options

 * added k6 scripts to test those graph/me/messages APIs, with a setup
   program to set up users in LDAP, fill their IMAP inbox, activate them
   in Stalwart, cleaning things up, etc...
2026-01-22 09:39:15 +01:00

242 lines
8.0 KiB
TypeScript

import { SharedArray } from 'k6/data'
import http from 'k6/http'
import encoding from 'k6/encoding'
import exec from 'k6/execution'
import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js'
import { check, fail, group } from 'k6'
import { Counter } from 'k6/metrics'
export const options = {
noConnectionReuse: true,
noVUConnectionReuse: true,
insecureSkipTLSVerify: true,
scenarios: {
rampup: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ target: 50, duration: '30s' },
{ target: 75, duration: '30s' },
{ target: 100, duration: '60s' },
{ target: 50, duration: '20s' },
],
gracefulRampDown: '10s',
},
},
}
const TEST_USER_NAMES: string|undefined = __ENV.TEST_USER_NAMES
const TEST_USER_PASSWORD: string = __ENV.TEST_USER_PASSWORD ?? 'demo'
const TEST_USER_DOMAIN: string = __ENV.TEST_USER_DOMAIN ?? 'example.org'
const CLOUD_URL: string = __ENV.CLOUD_URL ?? 'https://cloud.opencloud.test'
const KEYCLOAK_URL: string = __ENV.KEYCLOAK_URL ?? 'https://keycloak.opencloud.test/realms/openCloud'
const KEYCLOAK_CLIENT_ID: string = __ENV.KEYCLOAK_CLIENT_ID ?? 'groupware'
const USERS_FILE: string = __ENV.USERS_FILE ?? 'users.csv'
const JWT_EXPIRATION_THRESHOLD_SECONDS: number = parseInt(__ENV.JWT_EXPIRATION_THRESHOLD_SECONDS ?? '2')
type JwtHeader = {
alg: string
typ: string
kid: string
}
type JwtPayload = {
exp: number
iat: number
}
type Jwt = {
header: JwtHeader
payload: JwtPayload
signature: string
}
function decodeJwt(token: string): Jwt {
const parts = token.split('.')
const header = JSON.parse(encoding.b64decode(parts[0], 'rawurl', 's')) as JwtHeader
const payload = JSON.parse(encoding.b64decode(parts[1], 'rawurl', 's')) as JwtPayload
const signature = encoding.b64decode(parts[2], 'rawurl', 's')
return {header: header, payload: payload, signature: signature} as Jwt
}
type User = {
name: string
password: string
mail: string
}
type Identity = {
id: string
name: string
email: string
replyTo: string | undefined
bcc: string | undefined
textSignature: string | undefined
htmlSignature: string | undefined
mayDelete: boolean
}
type IdentityGetResponse = {
accountId: string
state: string
list: Identity[]
notFound: string[] | undefined
}
type VacationResponseGetResponse = {
accountId: string
state: string
notFound: string[]
}
type EmailAddress = {
name: string | undefined
address: string
}
type Message = {
'@odata.etag': string
id: string
createdDateTime: string
receivedDateTime: string
sentDateTime: string
internetMessageId: string
subject: string
bodyPreview: string
from: EmailAddress | undefined
toRecipients: EmailAddress[]
ccRecipients: EmailAddress[]
parentFolderId: string
conversationId: string
webLink: string
}
type Messages = {
'@odata.context': string
value: Message[]
}
function token(user: User): string {
const res = http.post(`${KEYCLOAK_URL}/protocol/openid-connect/token`, {
client_id: KEYCLOAK_CLIENT_ID,
scope: 'openid',
grant_type: 'password',
username: user.name,
password: user.password,
})
if (res.status !== 200) {
fail(`failed to retrieve token for ${user.name}: ${res.status} ${res.status_text}`)
}
const accessToken = res.json('access_token')?.toString()
if (accessToken === undefined) {
fail(`access token is empty for ${user.name}`)
} else {
return accessToken
}
}
function authenticate(user: User): Auth {
const raw = token(user)
const jwt = decodeJwt(raw)
return {raw: raw, jwt: jwt} as Auth
}
const users: User[] = new SharedArray('users', function () {
if (TEST_USER_NAMES) {
return TEST_USER_NAMES.split(',').map((name) => { return {name: name, password: TEST_USER_PASSWORD, mail: `${name}@${TEST_USER_DOMAIN}`} as User })
} else {
return papaparse.parse(open(USERS_FILE), { header: true, skipEmptyLines: true, }).data.map((row:object) => row as User)
}
})
type Auth = {
raw: string
jwt: Jwt
}
type TestData = {
auth: object
}
export function setup(): TestData {
const auth = {}
for (const user of users) {
const a = authenticate(user)
auth[user.name] = a
}
return {
auth: auth,
} as TestData
}
const stalwartIdRegex = /^[0-9a-z]+$/
export default function testSuite(data: TestData) {
const user = randomItem(users) as User
let auth = data.auth[user.name]
if (auth === undefined) {
fail(`missing authentication for user ${user.name}`)
}
const now = Math.floor(Date.now() / 1000)
if (auth.jwt.payload.exp - now < JWT_EXPIRATION_THRESHOLD_SECONDS) {
exec.test.abort(`token is expired for ${user.name}, need to renew`)
}
group('retrieve user identity using /me/identity', () => {
const res = http.get(`${CLOUD_URL}/graph/v1.0/me/identity`, {headers: {Authorization: `Bearer ${auth.raw}`}})
check(res, {
'is status 200': (r) => r.status === 200,
});
const response = res.json() as IdentityGetResponse
check(response, {
'identity response has an accountId': r => r.accountId !== undefined && stalwartIdRegex.test(r.accountId),
'identity response has a state': r => r.state !== undefined && stalwartIdRegex.test(r.state),
'identity response has an empty notFound': r => r.notFound === undefined,
'identity response has one identity item in its list': r => r.list && r.list.length === 1,
'identity response has one identity item with an id': r => r.list && r.list.length === 1 && stalwartIdRegex.test(r.list[0].id),
'identity response has one identity item with a name': r => r.list && r.list.length === 1 && r.list[0].name !== undefined,
'identity response has one identity item with the expected email': r => r.list && r.list.length === 1 && r.list[0].email === user.mail,
'identity response has one identity item with mayDelete=true': r => r.list && r.list.length === 1 && r.list[0].mayDelete === true,
'identity response has one identity item with an empty replyTo': r => r.list && r.list.length === 1 && r.list[0].replyTo === undefined,
'identity response has one identity item with an empty bcc': r => r.list && r.list.length === 1 && r.list[0].bcc === undefined,
'identity response has one identity item with an empty textSignature': r => r.list && r.list.length === 1 && r.list[0].textSignature === undefined,
'identity response has one identity item with an empty htmlSignature': r => r.list && r.list.length === 1 && r.list[0].htmlSignature === undefined,
})
})
group('retrieve user vacationresponse using /me/vacation', () => {
const res = http.get(`${CLOUD_URL}/graph/v1.0/me/vacation`, {headers: {Authorization: `Bearer ${auth.raw}`}})
check(res, {
'is status 200': (r) => r.status === 200,
});
const response = res.json() as VacationResponseGetResponse
check(response, {
'vacation response has an accountId': r => r.accountId !== undefined && stalwartIdRegex.test(r.accountId),
'vacation response has a state': r => r.state !== undefined && stalwartIdRegex.test(r.state),
'vacation response has a notFound that only contains "singleton"': r => r.notFound && r.notFound.length == 1 && r.notFound[0] == 'singleton',
})
})
group('retrieve user top message using /me/messages', () => {
const url = new URL(`${CLOUD_URL}/graph/v1.0/me/messages`)
url.searchParams.append('$top', '1')
const res = http.get(url.toString(), {headers: {Authorization: `Bearer ${auth.raw}`}})
check(res, {
'is status 200': (r) => r.status === 200,
});
const response = res.json() as Messages
check(response, {
'messages has a context': r => r['@odata.context'] !== undefined,
'messages has a value with a length of 0 or 1': r => r.value !== undefined && (r.value.length === 0 || r.value.length === 1),
'if there is a message, it has a subject': r => r.value !== undefined && (r.value.length === 0 || r.value[0].subject !== ''),
})
})
}