Skip to content

Commit 7975312

Browse files
authored
Add authentication to load tests (#3246)
* Update auth setup * Latest * Escape regex input * Fixes * Latest * bot feedback * Fix readme * Addressing some feedback * Feedback
1 parent 9555951 commit 7975312

12 files changed

Lines changed: 352 additions & 47 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,5 @@ backups/
147147
/playwright/.cache/
148148

149149
.claude
150+
151+
load_testing/data/

load_testing/README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,33 @@
33
### Usage (Docker)
44

55
```shell
6-
./scripts/k6.sh -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
6+
./scripts/k6.sh run /app/learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
77
```
88

99
### Usage (local k6)
1010

1111
- Install [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
1212

1313
```shell
14-
k6 run learn.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
14+
k6 run learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
1515
```
16+
17+
### Available tests
18+
19+
**Note: the numbers for average-load/stress should be updated over time**
20+
21+
- `learn.smoke.ts` - for lightweight smoke testing of deployments (4 users)
22+
- `learn.average-load.ts` - simulates an average amount of user load (100 users)
23+
- `learn.stress.ts` - simulates a stressful amount of user load (200 users)
24+
25+
### Environment Variables
26+
27+
**Note: for base urls, if you access the services via a port, include the port number**
28+
29+
| Name | Decription | Example value |
30+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
31+
| `BACKEND_BASE_URL` | The base url to the backend API service. | `https://api.learn.odl.local` |
32+
| `FRONTEND_BASE_URL` | The base url to the frontend service. | `https://learn.odl.local` |
33+
| `SSO_BASE_URL` | The base url to the keycloak service. | `https://keycloak.odl.local` |
34+
| `IGNORE_HTTPS_ERRORS` | Ignore https certificate errors. Only recommemded for local test certificates. | `true` |
35+
| `USERS_JSON_FILE` | Data file for users auth info. Expected to be in the format `[{"email": "<EMAIL>", "password": "<PASSWORD>"}, ...]`. Put this is the `data/` subdirectory as files in there are gitignored. | `data/users.json` |

load_testing/auth.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
import { SharedArray } from "k6/data"
22

3-
export function getAccessToken(): String {
3+
export type AuthCredential = {
4+
email: string
5+
password: string
6+
}
7+
8+
export function getAccessToken(): string | null {
49
return __ENV.AUTH_ACCESS_TOKEN
510
}
611

7-
export const users = new SharedArray("users", function () {
8-
if (!__ENV.USERS_JSON_FILE) {
9-
return []
12+
export function hasAccessToken(): boolean {
13+
return !!getAccessToken()
14+
}
15+
16+
function _validate_credentials(credentials) {
17+
if (!Array.isArray(credentials)) {
18+
throw Error("Expected an array of credentials")
19+
}
20+
21+
for (let index = 0; index < credentials.length; index++) {
22+
const credential = credentials[index]
23+
if (!Object.hasOwn(credential, "email")) {
24+
throw Error(`User entry is missing 'email' at index ${index}`)
25+
}
26+
if (!Object.hasOwn(credential, "password")) {
27+
throw Error(`User entry is missing 'password' at index ${index}`)
28+
}
1029
}
30+
}
31+
32+
export const credentials: AuthCredential[] = new SharedArray(
33+
"credentials",
34+
function () {
35+
if (!__ENV.USERS_JSON_FILE) {
36+
return []
37+
}
38+
39+
const parsed = JSON.parse(open(__ENV.USERS_JSON_FILE))
40+
41+
_validate_credentials(parsed)
1142

12-
return JSON.parse(open(__ENV.USERS_JSON_FILE))
13-
})
43+
return parsed
44+
},
45+
)

load_testing/backend/test.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,39 @@ import {
88
import { createV0Client, createV1Client } from "./client/client.ts"
99
import { NewsEventsListParams } from "./client/v0/api.schemas.ts"
1010
import { FeaturedListParams } from "./client/v1/api.schemas.ts"
11+
import { hasAccessToken } from "../auth.ts"
1112

1213
export function testBackend() {
1314
group("api", function () {
1415
group("v0", function () {
1516
exec.vu.metrics.tags.apiVersion = "v0"
1617

1718
const client = createV0Client()
18-
let res = client.videoShortsList({ limit: 50 })
19+
group("video shorts", () => {
20+
const res = client.videoShortsList({ limit: 50 })
1921

20-
check(res, {
21-
"is status 200": (r) => r.response.status === 200,
22-
"has results": (r) => r.response.json("results").length > 0,
22+
check(res, {
23+
"is status 200": (r) => r.response.status === 200,
24+
"has results": (r) => r.response.json("results").length > 0,
25+
})
2326
})
2427

25-
res = client.usersMeRetrieve()
26-
check(res, {
27-
"is status 200": (r) => r.response.status === 200,
28+
group("users/me", () => {
29+
const res = client.usersMeRetrieve()
30+
check(res, {
31+
"is status 200": (r) => r.response.status === 200,
32+
"expected auth state": (r) =>
33+
r.response.json("is_authenticated") === hasAccessToken(),
34+
})
2835
})
2936

30-
res = client.testimonialsList({ position: 1 })
37+
group("testimonials", () => {
38+
const res = client.testimonialsList({ position: 1 })
3139

32-
check(res, {
33-
"is status 200": (r) => r.response.status === 200,
34-
"has results": (r) => r.response.json("results").length > 0,
40+
check(res, {
41+
"is status 200": (r) => r.response.status === 200,
42+
"has results": (r) => r.response.json("results").length > 0,
43+
})
3544
})
3645

3746
group("news", function () {

load_testing/config.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { NewBrowserContextOptions } from "k6/browser"
22

3-
export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL
4-
export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL
3+
export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL?.replace(
4+
/\/$/,
5+
"",
6+
)
7+
export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL?.replace(
8+
/\/$/,
9+
"",
10+
)
11+
export const SSO_BASE_URL: string = (
12+
__ENV.SSO_BASE_URL ?? "http://localhost"
13+
).replace(/\/$/, "")
14+
15+
export const IGNORE_HTTPS_ERRORS: boolean =
16+
(__ENV.IGNORE_HTTPS_ERRORS || "false").toLowerCase() == "true"
517

618
export const BROWSER_CONTEXT_OPTIONS: NewBrowserContextOptions = {
7-
ignoreHTTPSErrors: Object.hasOwn(__ENV, "BROWSER_IGNORE_HTTPS_ERRORS")
8-
? __ENV.BROWSER_IGNORE_HTTPS_ERRORS
9-
: false,
19+
ignoreHTTPSErrors: IGNORE_HTTPS_ERRORS,
1020
}

load_testing/data/.keep

Whitespace-only changes.

load_testing/frontend/test.ts

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,180 @@
11
import { check } from "k6"
22
import { browser, Page } from "k6/browser"
3-
import { randomIntBetween } from "https://jslib.k6.io/k6-utils/1.2.0/index.js"
4-
import { BROWSER_CONTEXT_OPTIONS, FRONTEND_BASE_URL } from "../config.ts"
3+
import {
4+
randomIntBetween,
5+
randomItem,
6+
} from "https://jslib.k6.io/k6-utils/1.2.0/index.js"
7+
import {
8+
BROWSER_CONTEXT_OPTIONS,
9+
FRONTEND_BASE_URL,
10+
SSO_BASE_URL,
11+
} from "../config.ts"
12+
import { AuthCredential, credentials } from "../auth.ts"
13+
import { escapeRegex } from "../utils.ts"
514

6-
async function home(page: Page) {
15+
type Context = {
16+
loggedIn: boolean
17+
credential: AuthCredential | null
18+
}
19+
20+
async function home(page: Page, context: Context) {
721
await page.goto(FRONTEND_BASE_URL)
822

923
const carousel = await page.getByTestId("resource-carousel")
10-
const articlesCount = await carousel.locator("article").count()
24+
const articles = carousel.locator("article")
25+
26+
try {
27+
await articles.first().waitFor({ timeout: 10_000 })
28+
} catch {
29+
// carousel never populated; let the assertion below report it
30+
}
31+
32+
const articlesCount = await articles.count()
33+
1134
await check(carousel, {
12-
"has 12 items": () => articlesCount === 12,
35+
"home page carousel has 12 items": () => articlesCount === 12,
1336
})
1437

15-
await carousel
16-
.locator("article")
17-
.nth(randomIntBetween(0, articlesCount - 1))
18-
.click()
38+
if (articlesCount === 0) {
39+
return
40+
}
41+
42+
await articles.nth(randomIntBetween(0, articlesCount - 1)).click()
1943
}
2044

21-
async function search(page: Page) {
45+
async function search(page: Page, context: Context) {
2246
await page.goto(`${FRONTEND_BASE_URL}/search?sortby=-views`)
2347
await page.goto(`${FRONTEND_BASE_URL}/search?resource_type_group=program`)
2448
await page.goto(
2549
`${FRONTEND_BASE_URL}/search?resource_type_group=learning_material`,
2650
)
2751
}
2852

29-
async function topics(page: Page) {
53+
async function topics(page: Page, context: Context) {
3054
await page.goto(`${FRONTEND_BASE_URL}/topics`)
3155
}
3256

33-
async function departments(page: Page) {
57+
async function departments(page: Page, context: Context) {
3458
await page.goto(`${FRONTEND_BASE_URL}/departments`)
3559
}
3660

37-
async function units(page: Page) {
61+
async function units(page: Page, context: Context) {
3862
await page.goto(`${FRONTEND_BASE_URL}/units`)
3963
}
4064

65+
async function login(page: Page, context: Context) {
66+
const credential: AuthCredential | undefined = randomItem(credentials)
67+
68+
if (credential == null) {
69+
console.log("Login > skipping because no credentials provided")
70+
return
71+
}
72+
73+
if (!SSO_BASE_URL) {
74+
throw Error("SSO_BASE_URL must be set to run the login flow")
75+
}
76+
77+
if (page.url() === "about:blank") {
78+
await page.goto(FRONTEND_BASE_URL)
79+
}
80+
81+
console.log(`Login > using '${credential.email}'`)
82+
83+
const loginButton = page.getByTestId("login-button-desktop")
84+
await loginButton.first().click()
85+
86+
let loggedIn = false
87+
88+
try {
89+
await loginKeycloak(page, credential, context)
90+
await page.locator('[aria-label="User Menu"]').waitFor({ timeout: 10_000 })
91+
loggedIn = true
92+
console.log("Login > authenticated; user menu visible")
93+
} catch (err) {
94+
console.log(
95+
`Login > failed for '${credential.email}' at URL '${page.url()}': ${err}`,
96+
)
97+
}
98+
99+
check(null, {
100+
"login succeeded": () => loggedIn,
101+
})
102+
}
103+
104+
const ESCAPED_SSO_URL = escapeRegex(SSO_BASE_URL)
105+
106+
const KEYCLOAK_USERNAME_URL_RE = new RegExp(
107+
`${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/protocol\/openid-connect\/auth.*`,
108+
)
109+
const KEYCLOAK_PASSWORD_URL_RE = new RegExp(
110+
`${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/login-actions\/authenticate.*`,
111+
)
112+
113+
async function loginKeycloak(
114+
page: Page,
115+
credential: AuthCredential,
116+
context: Context,
117+
) {
118+
await page.waitForURL(KEYCLOAK_USERNAME_URL_RE, {
119+
waitUntil: "load",
120+
})
121+
122+
console.log("Login > Keycloak > on login email page")
123+
124+
const credentialnameInput = await page.locator("input[name=username]")
125+
console.log("Login > Keycloak > found username input")
126+
await credentialnameInput.focus()
127+
await credentialnameInput.fill(credential.email)
128+
console.log("Login > Keycloak > entered email address")
129+
130+
await page.locator("button[type=submit]").click()
131+
console.log("Login > Keycloak > submitting email address")
132+
133+
await page.waitForURL(KEYCLOAK_PASSWORD_URL_RE)
134+
console.log("Login > Keycloak > on login password page")
135+
136+
const passwordInput = await page.locator("input[name=password]")
137+
console.log("Login > Keycloak > found password input")
138+
await passwordInput.focus()
139+
await passwordInput.fill(credential.password, {
140+
force: true,
141+
})
142+
await page.locator("button[type=submit]").click()
143+
console.log("Login > Keycloak > submitting password")
144+
await page.waitForNavigation()
145+
146+
context.loggedIn = true
147+
context.credential = credential
148+
}
149+
150+
async function dashboard(page: Page, context: Context) {
151+
if (!context.loggedIn) {
152+
return
153+
}
154+
await page.goto(`${FRONTEND_BASE_URL}/dashboard`)
155+
}
156+
41157
export async function testFrontend() {
42158
const page = await browser.newPage(BROWSER_CONTEXT_OPTIONS)
159+
const context: Context = {
160+
loggedIn: false,
161+
credential: null,
162+
}
43163

44164
try {
45-
await home(page)
46-
await search(page)
47-
await topics(page)
48-
await departments(page)
49-
await units(page)
165+
// always home page first
166+
await home(page, context)
167+
await search(page, context)
168+
await topics(page, context)
169+
await departments(page, context)
170+
await units(page, context)
171+
await login(page, context)
172+
await dashboard(page, context)
173+
await search(page, context)
174+
await topics(page, context)
175+
await departments(page, context)
176+
await units(page, context)
177+
await home(page, context)
50178
} finally {
51179
await page.close()
52180
}

0 commit comments

Comments
 (0)