mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-23 21:42:23 -05:00
* 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...
242 lines
8.0 KiB
TypeScript
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 !== ''),
|
|
})
|
|
})
|
|
}
|