Skip to content

Commit ff4fb56

Browse files
boehlkeclaude
andcommitted
test: add OIDC E2E tests for Keycloak integration
- Playwright test infrastructure for OIDC flows - Login/logout, account management, user sync tests - Keycloak admin helper for test setup/teardown - Docker Compose config for OIDC test environment Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent b664417 commit ff4fb56

20 files changed

+3306
-14
lines changed

client/tests/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
node_modules/
22
/test-results/
3+
/test-results-oidc/
34
/playwright-report/
5+
/playwright-report-oidc/
46
/playwright/.cache/
7+
8+
# OIDC test state (generated by global setup)
9+
integration/.oidc-test-state.json

client/tests/Dockerfile.oidc-test

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
2+
3+
WORKDIR /app
4+
5+
# Install dependencies
6+
COPY ./package.json .
7+
COPY ./package-lock.json .
8+
ENV CI=1
9+
RUN npm ci
10+
11+
# Copy test configuration and fixtures
12+
COPY ./playwright.oidc.config.ts .
13+
COPY ./integration ./integration
14+
15+
# Default command runs OIDC tests
16+
CMD ["npx", "playwright", "test", "--config=playwright.oidc.config.ts"]

client/tests/Dockerfile.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM mcr.microsoft.com/playwright:v1.58.2-jammy
1+
FROM mcr.microsoft.com/playwright:v1.58.1-jammy
22

33
WORKDIR /app
44
COPY ./package.json .
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
version: '3.8'
2+
3+
#
4+
# Docker Compose configuration for OIDC E2E tests in CI.
5+
#
6+
# This extends the standard test configuration to include Keycloak checks.
7+
#
8+
# Usage:
9+
# docker compose -f docker-compose.oidc-test.yml up --exit-code-from playwright
10+
#
11+
12+
services:
13+
playwright:
14+
build:
15+
context: .
16+
dockerfile: Dockerfile.oidc-test
17+
image: playwright-oidc
18+
container_name: playwright-oidc
19+
environment:
20+
- CI=true
21+
- BASE_URL=https://localhost:8000
22+
- KEYCLOAK_URL=http://localhost:8080
23+
- SKIP_OIDC_TESTS=false
24+
- KEYCLOAK_AVAILABLE=true
25+
volumes:
26+
- ./integration:/app/integration
27+
- ./fixtures:/app/fixtures
28+
- ./playwright.oidc.config.ts:/app/playwright.oidc.config.ts
29+
- ./playwright-report-oidc:/app/playwright-report-oidc
30+
- ./test-results-oidc:/app/test-results-oidc
31+
network_mode: host
32+
depends_on:
33+
keycloak-health:
34+
condition: service_healthy
35+
openslides-health:
36+
condition: service_healthy
37+
38+
# Health check sidecar for Keycloak
39+
keycloak-health:
40+
image: curlimages/curl:latest
41+
container_name: keycloak-health
42+
entrypoint: ["/bin/sh", "-c"]
43+
command:
44+
- |
45+
echo "Waiting for Keycloak..."
46+
until curl -sf http://localhost:8080/auth/realms/openslides/.well-known/openid-configuration > /dev/null 2>&1; do
47+
echo "Keycloak not ready, waiting..."
48+
sleep 2
49+
done
50+
echo "Keycloak is ready!"
51+
# Keep container running for health check
52+
tail -f /dev/null
53+
network_mode: host
54+
healthcheck:
55+
test: ["CMD", "curl", "-sf", "http://localhost:8080/auth/realms/openslides/.well-known/openid-configuration"]
56+
interval: 5s
57+
timeout: 10s
58+
retries: 30
59+
start_period: 10s
60+
61+
# Health check sidecar for OpenSlides
62+
openslides-health:
63+
image: curlimages/curl:latest
64+
container_name: openslides-health
65+
entrypoint: ["/bin/sh", "-c"]
66+
command:
67+
- |
68+
echo "Waiting for OpenSlides..."
69+
until curl -kf https://localhost:8000/system/auth/ > /dev/null 2>&1; do
70+
echo "OpenSlides not ready, waiting..."
71+
sleep 2
72+
done
73+
echo "OpenSlides is ready!"
74+
# Keep container running for health check
75+
tail -f /dev/null
76+
network_mode: host
77+
healthcheck:
78+
test: ["CMD", "curl", "-kf", "https://localhost:8000/system/auth/"]
79+
interval: 5s
80+
timeout: 10s
81+
retries: 30
82+
start_period: 10s
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { test as base, expect, Page, BrowserContext } from '@playwright/test';
2+
3+
import { KeycloakLoginPage } from '../helpers/oidc-auth';
4+
import { getKeycloakAdminClient } from '../helpers/keycloak-admin';
5+
import { loadTestState } from '../helpers/test-state';
6+
7+
/**
8+
* OIDC Test Fixtures
9+
*
10+
* Provides reusable fixtures for OIDC E2E tests with:
11+
* - Dynamic realm support (from global setup)
12+
* - Browser context isolation
13+
* - Automatic session cleanup
14+
*/
15+
16+
export interface OIDCTestConfig {
17+
keycloakBaseUrl: string;
18+
keycloakRealm: string;
19+
openslidesBaseUrl: string;
20+
testUsers: {
21+
admin: { username: string; password: string; userId: number };
22+
testuser: { username: string; password: string; userId: number };
23+
};
24+
}
25+
26+
function getOIDCConfig(): OIDCTestConfig {
27+
// Get realm from test state (set by global setup) or fallback to env/default
28+
const state = loadTestState();
29+
const realm = state?.realmName || process.env.KEYCLOAK_REALM || 'openslides';
30+
31+
return {
32+
keycloakBaseUrl: process.env.KEYCLOAK_URL || 'http://localhost:8180',
33+
keycloakRealm: realm,
34+
openslidesBaseUrl: process.env.BASE_URL || 'https://localhost:8000',
35+
testUsers: {
36+
admin: { username: 'admin', password: 'admin', userId: 1 },
37+
testuser: { username: 'testuser', password: 'testpassword', userId: 2 }
38+
}
39+
};
40+
}
41+
42+
export interface OIDCFixtures {
43+
oidcConfig: OIDCTestConfig;
44+
oidcEnabledContext: BrowserContext;
45+
isolatedContext: BrowserContext;
46+
isolatedPage: Page;
47+
keycloakPage: KeycloakLoginPage;
48+
cleanupSession: () => Promise<void>;
49+
}
50+
51+
/**
52+
* OIDC is now configured via environment variables, not organization settings.
53+
* These functions are no-ops kept for test compatibility.
54+
* OIDC is enabled when the Traefik OIDC middleware is configured.
55+
*/
56+
async function enableOIDC(_context: BrowserContext): Promise<void> {
57+
// OIDC is enabled via Traefik middleware configuration, not organization settings.
58+
// This is a no-op for test compatibility.
59+
}
60+
61+
async function disableOIDC(_context: BrowserContext): Promise<void> {
62+
// OIDC is enabled via Traefik middleware configuration, not organization settings.
63+
// This is a no-op for test compatibility.
64+
}
65+
66+
/**
67+
* Extended test object with OIDC fixtures.
68+
*/
69+
export const test = base.extend<OIDCFixtures>({
70+
oidcConfig: async ({}, use) => {
71+
await use(getOIDCConfig());
72+
},
73+
74+
/**
75+
* Isolated browser context with clean state.
76+
* Cookies are cleared before and after use.
77+
*/
78+
isolatedContext: async ({ browser }, use) => {
79+
const context = await browser.newContext({
80+
storageState: undefined, // Fresh state
81+
serviceWorkers: 'block' // Prevent SW interference
82+
});
83+
84+
// Clear any cookies that might exist
85+
await context.clearCookies();
86+
87+
await use(context);
88+
89+
// Cleanup after test
90+
await context.clearCookies();
91+
await context.close();
92+
},
93+
94+
/**
95+
* Page with isolated context and cleared storage.
96+
*/
97+
isolatedPage: async ({ isolatedContext }, use) => {
98+
const page = await isolatedContext.newPage();
99+
100+
// Clear local/session storage on navigation
101+
await page.addInitScript(() => {
102+
localStorage.clear();
103+
sessionStorage.clear();
104+
});
105+
106+
await use(page);
107+
108+
await page.close();
109+
},
110+
111+
/**
112+
* Keycloak login page helper.
113+
*/
114+
keycloakPage: async ({ page, oidcConfig }, use) => {
115+
const keycloakPage = new KeycloakLoginPage(page, {
116+
keycloakBaseUrl: oidcConfig.keycloakBaseUrl,
117+
realm: oidcConfig.keycloakRealm,
118+
clientId: 'openslides-client'
119+
});
120+
await use(keycloakPage);
121+
},
122+
123+
/**
124+
* Session cleanup helper that clears Keycloak sessions for test users.
125+
*/
126+
cleanupSession: async ({ oidcConfig }, use) => {
127+
const cleanup = async () => {
128+
try {
129+
const admin = getKeycloakAdminClient();
130+
await admin.authenticate();
131+
132+
// Clear sessions for test users
133+
for (const userKey of ['admin', 'testuser'] as const) {
134+
const user = oidcConfig.testUsers[userKey];
135+
await admin.clearUserSessionsByUsername(oidcConfig.keycloakRealm, user.username);
136+
}
137+
} catch (error) {
138+
console.warn('[OIDC Fixture] Session cleanup warning:', error);
139+
}
140+
};
141+
142+
await use(cleanup);
143+
144+
// Auto-cleanup after test
145+
await cleanup();
146+
}
147+
});
148+
149+
export { expect };
150+
151+
/**
152+
* Helper to wait for OIDC redirect to complete.
153+
*/
154+
export async function waitForOIDCRedirect(page: Page, options?: { timeout?: number }): Promise<void> {
155+
const timeout = options?.timeout || 30000;
156+
157+
// Wait until we're no longer on the Keycloak login page
158+
await expect(page).not.toHaveURL(/login-actions/, { timeout });
159+
160+
// Wait until we're no longer on OpenSlides login page
161+
await expect(page).not.toHaveURL(/\/login$/, { timeout });
162+
}
163+
164+
/**
165+
* Helper to initiate OIDC login flow with robust button detection.
166+
*/
167+
export async function initiateOIDCFlow(page: Page): Promise<void> {
168+
await page.goto('/login');
169+
await page.waitForLoadState('networkidle');
170+
171+
// Wait for Angular app to be ready
172+
await page.waitForSelector('os-login-mask', {
173+
state: 'visible',
174+
timeout: 15000
175+
});
176+
177+
// Prioritized selector strategy (most specific to least)
178+
const ssoSelectors = [
179+
'[data-testid="sso-login-button"]', // Preferred: explicit test ID
180+
'[data-cy="sso-login"]', // Alternative test attribute
181+
'button:has-text("SSO")', // Text-based fallback
182+
'button:has-text("OIDC")', // Alternative text
183+
'button:has-text("Single Sign")' // Full text variant
184+
];
185+
186+
for (const selector of ssoSelectors) {
187+
const button = page.locator(selector);
188+
const isVisible = await button.isVisible({ timeout: 2000 }).catch(() => false);
189+
190+
if (isVisible) {
191+
console.log(`[OIDC Fixture] Found SSO button with selector: ${selector}`);
192+
await button.click();
193+
return;
194+
}
195+
}
196+
197+
// Last resort: find button containing SSO-related text via regex
198+
const genericButton = page
199+
.locator('button')
200+
.filter({
201+
hasText: /sso|oidc|single.?sign/i
202+
})
203+
.first();
204+
205+
if (await genericButton.isVisible({ timeout: 2000 }).catch(() => false)) {
206+
console.log('[OIDC Fixture] Found SSO button via regex filter');
207+
await genericButton.click();
208+
return;
209+
}
210+
211+
throw new Error(
212+
'No SSO login button found. Ensure OIDC is enabled and the button has ' +
213+
'data-testid="sso-login-button" attribute.'
214+
);
215+
}

0 commit comments

Comments
 (0)