Add basic test suite setup (#55)

Closes #53

Signed-off-by: Jon Koops <jonkoops@gmail.com>
Signed-off-by: Peter Skopek <pskopek@redhat.com>
Co-authored-by: Peter Skopek <pskopek@redhat.com>
This commit is contained in:
Jon Koops
2025-04-03 09:14:28 +02:00
committed by GitHub
parent 243f1a9f50
commit 8b85448c21
16 changed files with 2059 additions and 208 deletions
+15 -6
View File
@@ -1,11 +1,20 @@
# Keycloak Javascript adapter # Keycloak JS
Client-side JavaScript library that can be used to secure web applications. Most of the contribution rules from the [main Keycloak repository](https://github.com/keycloak/keycloak/blob/main/CONTRIBUTING.md) apply Keycloak JS as well. Documented below are steps unique to the development of Keycloak JS.
Most of the contribution rules from the [main Keycloak repository](https://github.com/keycloak/keycloak/blob/main/CONTRIBUTING.md) applies to the ## Prerequisites
Keycloak Javascript adapter as well. Below some rules specific to javascript adapter.
## Building and working with the codebase - [Node.js](https://nodejs.org/en/download) (latest LTS or greater)
- [Podman](https://podman.io/) (for testing)
TODO ## Setup
Make sure that all dependencies are installed by running the following command in the root of the project:
```sh
npm install
```
## Running the tests
To run the tests, follow the instructions in the [test directory](./test/README.md).
+1568 -199
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -4,9 +4,12 @@
"type": "module", "type": "module",
"description": "A client-side JavaScript OpenID Connect library that can be used to secure web applications.", "description": "A client-side JavaScript OpenID Connect library that can be used to secure web applications.",
"scripts": { "scripts": {
"lint": "standard", "lint": "ts-standard",
"guides": "node docs/guides/guides.mjs $npm_package_version" "guides": "node docs/guides/guides.mjs $npm_package_version"
}, },
"workspaces": [
"test"
],
"exports": { "exports": {
".": { ".": {
"types": "./lib/keycloak.d.ts", "types": "./lib/keycloak.d.ts",
@@ -37,13 +40,13 @@
"oauth2", "oauth2",
"authentication" "authentication"
], ],
"standard": { "ts-standard": {
"ignore": [ "ignore": [
"lib/*" "lib/*"
] ]
}, },
"devDependencies": { "devDependencies": {
"jszip": "^3.10.1", "jszip": "^3.10.1",
"standard": "^17.1.2" "ts-standard": "^12.0.2"
} }
} }
+5
View File
@@ -0,0 +1,5 @@
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+102
View File
@@ -0,0 +1,102 @@
# Keycloak JS test suite
This directory contains the test suite for Keycloak JS, which is based on [Playwright](https://playwright.dev/). It contains a suite of integration tests that embed the adapter in various scenarios and tests it against a Keycloak server running in the background.
## Setup
Run the following command to install the [Playwright browsers](https://playwright.dev/docs/browsers) and required system dependencies:
```sh
npx playwright install
```
It might be that this command fails due to missing system dependencies, in that case add the `--with-deps` flag:
```sh
npx playwright install --with-deps
```
### Setup on Linux distributions unsupported by Playwright
Playwright doesn't support some Linux-based distributions, if you are on Linux and the installation steps above did not work for you, follow the steps below, otherwise, skip to [running the tests](#running-the-tests).
In order to run the tests on unsupported distributions you can use Distrobox to run an Ubuntu 22.04 image on top of your host system, which is supported by Playwright.
#### 1. Install `distrobox` and `podman` packages
First, install both [Distrobox](https://distrobox.it/#installation) and [Podman](https://podman.io/docs/installation). Then, create home directory for your Distrobox environment (this helps avoid conflicts with your host system's home directory):
```sh
mkdir ~/distrobox
```
#### 2. Create a container environment to run tests
Create a container in your host (for more information see the [documentation](https://distrobox.it/)):
```sh
distrobox create \
--name pw --image ubuntu:22.04 \
--home ~/distrobox \
--root \
--additional-packages "podman libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb" \
--unshare-all \
--absolutely-disable-root-password-i-am-really-positively-sure
```
Now enter the created Distrobox environment using:
```sh
distrobox enter --root pw
```
#### 3. Install Node.js
Whilst inside of the Distrobox environment, install Node.js by following the instructions from the [Node.js download page](https://nodejs.org/en/download). Using the latest LTS version is recommended.
It should now be possible to install the Playwright browsers by running the following command from the project root:
```sh
npx playwright install --with-deps
```
## Running the tests
Make sure you are in the `test` directory. To run the tests headlessly you can run the following command:
```sh
npm test
```
It is also possible to run the tests in [various other modes](https://playwright.dev/docs/running-tests), for example, to debug the tests `--debug` can be passed:
```sh
npm test -- --debug
```
## Speeding up testing
By default, the tests will run against a Keycloak server that is running the latest version. This server is started by Playwright using Podman by running the following command:
```sh
podman run -p 8080:8080 -p 9000:9000 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin -e KC_HEALTH_ENABLED=true --pull=newer quay.io/keycloak/keycloak:latest start-dev
```
Alternatively, if you want to run the Keycloak server straight from the distribution (or your local development instance), without using Podman you can run it as follows:
```sh
KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_PASSWORD=admin KC_HEALTH_ENABLED=true ./bin/kc.sh start-dev
```
Every time the tests run the Keycloak server will also be restarted, which can slow down development. You can instead opt to keep a Keycloak server running in the background, and re-use this server. To do so, remove the `gracefulShutdown` section from the Playwright configuration (`playwright.config.ts`):
```diff
{
- gracefulShutdown: {
- // Podman requires a termination signal to stop.
- signal: 'SIGTERM',
- timeout: 5000
- }
},
```
+12
View File
@@ -0,0 +1,12 @@
import express from 'express'
import path from 'node:path'
// Set up Express
const app = express()
// Expose 'public' directory and Keycloak JS source.
app.use(express.static(path.resolve(import.meta.dirname, 'public')))
app.use(express.static(path.resolve(import.meta.dirname, '../../lib')))
// Start server
app.listen(3000)
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Keycloak JS Tests</title>
<meta name="viewport" content="width=device-width">
<meta name="color-scheme" content="light dark">
<script type="importmap">
{
"imports": {
"keycloak-js": "./keycloak.js"
}
}
</script>
<link rel="modulepreload" href="./keycloak.js">
</head>
<body>
<script type="module">
import Keycloak from 'keycloak-js'
globalThis.Keycloak = Keycloak
globalThis.keycloak = null
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<html>
<body>
<script>
parent.postMessage(location.href, location.origin)
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "keycloak-js-test",
"type": "module",
"private": true,
"scripts": {
"test": "playwright test",
"app": "node app/app.js"
},
"devDependencies": {
"@keycloak/keycloak-admin-client": "^26.1.4",
"@playwright/test": "^1.51.1",
"@types/express": "^5.0.1",
"@types/node": "^22.13.14",
"express": "^5.1.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from '@playwright/test'
import { APP_HOST } from './support/common.ts'
const KEYCLOAK_VERSION = 'latest'
export default defineConfig({
webServer: [{
command: `podman run -p 8080:8080 -p 9000:9000 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin -e KC_HEALTH_ENABLED=true --pull=newer quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} start-dev`,
url: 'http://localhost:9000/health/live',
stdout: 'pipe',
reuseExistingServer: true,
gracefulShutdown: {
// Podman requires a termination signal to stop.
signal: 'SIGTERM',
timeout: 5000
}
}, {
command: 'npm run app',
port: 3000,
stdout: 'pipe'
}],
use: {
baseURL: APP_HOST
}
})
+13
View File
@@ -0,0 +1,13 @@
import AdminClient from '@keycloak/keycloak-admin-client'
import { ADMIN_PASSWORD, ADMIN_USERNAME, AUTH_SERVER_HOST } from './common.ts'
export const adminClient = new AdminClient({
baseUrl: AUTH_SERVER_HOST
})
await adminClient.auth({
username: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
grantType: 'password',
clientId: 'admin-cli'
})
+9
View File
@@ -0,0 +1,9 @@
export const ADMIN_USERNAME = 'admin'
export const ADMIN_PASSWORD = 'admin'
export const APP_HOST = 'http://localhost:3000'
export const AUTH_SERVER_HOST = 'http://localhost:8080'
export const CLIENT_ID = 'keycloak-js-test-client'
export const AUTHORIZED_USERNAME = 'test-user@localhost'
export const AUTHORIZED_PASSWORD = 'password'
export const UNAUTHORIZED_USERNAME = 'unauthorized'
export const UNAUTHORIZED_PASSWORD = 'password'
+80
View File
@@ -0,0 +1,80 @@
import type CredentialRepresentation from '@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation.ts'
import { adminClient } from './admin-client.ts'
import { APP_HOST, AUTHORIZED_PASSWORD, AUTHORIZED_USERNAME, CLIENT_ID, UNAUTHORIZED_PASSWORD, UNAUTHORIZED_USERNAME } from './common.ts'
export async function createTestResources (): Promise<string> {
const { realmName } = await adminClient.realms.create({
realm: crypto.randomUUID(),
enabled: true
})
await Promise.all([
adminClient.roles.create({
realm: realmName,
name: 'user',
scopeParamRequired: false
}),
adminClient.roles.create({
realm: realmName,
name: 'admin',
scopeParamRequired: false
})
])
await Promise.all([
createUserWithCredential({
realm: realmName,
enabled: true,
username: AUTHORIZED_USERNAME,
firstName: 'Authorized',
lastName: 'User',
email: 'test-user@localhost',
emailVerified: true,
realmRoles: ['user'],
clientRoles: {
'realm-management': ['view-realm', 'manage-users'],
account: ['view-profile', 'manage-account']
}
}, {
temporary: false,
type: 'password',
value: AUTHORIZED_PASSWORD
}),
createUserWithCredential({
realm: realmName,
enabled: true,
username: UNAUTHORIZED_USERNAME,
firstName: 'Unauthorized',
lastName: 'User',
email: 'unauthorized@localhost',
emailVerified: true
}, {
temporary: false,
type: 'password',
value: UNAUTHORIZED_PASSWORD
})
])
await adminClient.clients.create({
realm: realmName,
enabled: true,
clientId: CLIENT_ID,
redirectUris: [`${APP_HOST}/*`],
webOrigins: [APP_HOST],
publicClient: true
})
return realmName
}
type CreateUserParams = NonNullable<Parameters<typeof adminClient.users.create>[0]>
async function createUserWithCredential (user: CreateUserParams, credential: CredentialRepresentation): Promise<void> {
const { id } = await adminClient.users.create(user)
await adminClient.users.resetPassword({
realm: user.realm,
id,
credential
})
}
+148
View File
@@ -0,0 +1,148 @@
import type { Page } from 'playwright'
import type Keycloak from '../../lib/keycloak.d.ts'
import type { KeycloakConfig, KeycloakInitOptions, KeycloakLoginOptions, KeycloakLogoutOptions } from '../../lib/keycloak.d.ts'
import { APP_HOST, AUTH_SERVER_HOST, AUTHORIZED_PASSWORD, AUTHORIZED_USERNAME, CLIENT_ID } from './common.ts'
export class TestExecutor {
readonly #page: Page
readonly #realm: string
constructor (page: Page, realm: string) {
this.#page = page
this.#realm = realm
}
async instantiateAdapter (config: KeycloakConfig = { url: AUTH_SERVER_HOST, realm: this.#realm, clientId: CLIENT_ID }): Promise<void> {
await this.#ensureOnAppPage()
await this.#page.evaluate((config) => {
(globalThis as any).keycloak = new (globalThis as any).Keycloak(config)
}, config)
}
async initializeAdapter (options: KeycloakInitOptions = { onLoad: 'check-sso' }): Promise<boolean> {
await this.#ensureOnAppPage()
await this.#ensureInstantiated()
let result
try {
// Because `.evaluate()` can throw an error if a navigation occurs, we need to capture the result
// to differentiate between the error thrown by the adapter and the error thrown by an unexpected navigation.
result = await this.#page.evaluate(async (options) => {
try {
const value = await ((globalThis as any).keycloak as Keycloak).init(options)
return { value, error: null }
} catch (error) {
return { value: null, error }
}
}, options)
} catch {
// The only reason an error is thrown here is because the page navigated, which is expected and can be ignored.
result = { value: null, error: null }
}
if (result.error !== null) {
// The error is not related to the navigation, so we need to throw it.
throw result.error as Error
}
return result.value ?? false
}
async submitLoginForm (username = AUTHORIZED_USERNAME, password = AUTHORIZED_PASSWORD): Promise<void> {
await this.#page.getByRole('textbox', { name: 'Username or email' }).fill(username)
await this.#page.getByRole('textbox', { name: 'Password' }).fill(password)
await this.#page.getByRole('button', { name: 'Sign In' }).click()
}
async login (options?: KeycloakLoginOptions): Promise<void> {
await this.#assertInstantiated()
let result
try {
// Because `.evaluate()` can throw an error if a navigation occurs, we need to capture the result
// to differentiate between the error thrown by the adapter and the error thrown by an unexpected navigation.
result = await this.#page.evaluate(async (options) => {
try {
await ((globalThis as any).keycloak as Keycloak).login(options)
return { error: null }
} catch (error) {
return { error }
}
}, options)
} catch {
// The only reason an error is thrown here is because the page navigated, which is expected and can be ignored.
result = { error: null }
}
if (result.error !== null) {
// The error is not related to the navigation, so we need to throw it.
throw result.error as Error
}
await this.#waitForLoginPage()
}
async logout (options?: KeycloakLogoutOptions): Promise<void> {
await this.#assertInstantiated()
let result
try {
// Because `.evaluate()` can throw an error if a navigation occurs, we need to capture the result
// to differentiate between the error thrown by the adapter and the error thrown by an unexpected navigation.
result = await this.#page.evaluate(async (options) => {
try {
await ((globalThis as any).keycloak as Keycloak).logout(options)
return { error: null }
} catch (error) {
return { error }
}
}, options)
} catch {
// The only reason an error is thrown here is because the page navigated, which is expected and can be ignored.
result = { error: null }
}
if (result.error !== null) {
// The error is not related to the navigation, so we need to throw it.
throw result.error as Error
}
await this.#waitForAppPage()
}
async #ensureOnAppPage (): Promise<void> {
if (!this.#page.url().startsWith(APP_HOST)) {
await this.#page.goto(APP_HOST)
}
}
async #ensureInstantiated (): Promise<void> {
if (!await this.#isInstantiated()) {
await this.instantiateAdapter()
}
}
async #assertInstantiated (): Promise<void> {
if (!await this.#isInstantiated()) {
throw new Error('The adapter is not instantiated, make sure the adapter is instantiated before calling this method.')
}
}
async #isInstantiated (): Promise<boolean> {
try {
return await this.#page.evaluate(() => {
return ((globalThis as any).keycloak as Keycloak | null) !== null
})
} catch {
return false
}
}
async #waitForAppPage (): Promise<void> {
await this.#page.waitForURL(APP_HOST + '/**')
}
async #waitForLoginPage (): Promise<void> {
await this.#page.waitForURL(AUTH_SERVER_HOST + '/**')
}
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test'
import { createTestResources } from '../support/helpers.ts'
import { TestExecutor } from '../support/test-executor.ts'
test('logs in and out', async ({ page }) => {
const realm = await createTestResources()
const executor = new TestExecutor(page, realm)
// Initially, no user should be authenticated.
expect(await executor.initializeAdapter()).toBe(false)
// After triggering a login, the user should be authenticated.
await executor.login()
await executor.submitLoginForm()
expect(await executor.initializeAdapter()).toBe(true)
// After logging out, the user should no longer be authenticated.
await executor.logout()
expect(await executor.initializeAdapter()).toBe(false)
})
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true
}
}