tests(e2e): use custom Playwright route for callback URL checks

This commit is contained in:
Elias Schneider
2026-05-29 09:44:37 +02:00
parent 6aefe6c8e1
commit f13424720b
+100 -149
View File
@@ -10,36 +10,21 @@ test.beforeEach(async () => await cleanupBackend());
test('Authorize existing client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
});
test('Authorize existing client while not signed in', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
});
});
@@ -51,17 +36,9 @@ test('Authorize new client', async ({ page }) => {
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
});
test('Authorize new client while not signed in', async ({ page }) => {
@@ -76,17 +53,9 @@ test('Authorize new client while not signed in', async ({ page }) => {
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
});
test('Authorize new client fails with user group not allowed', async ({ page }) => {
@@ -130,24 +99,16 @@ test('End session without id token hint shows confirmation page', async ({ page
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken("fe81c12a-7336-4aee-bebc-d901a873bf48", users.tim, client.id);
let redirectedCorrectly = false;
await page
.goto(
const idToken = await generateIdToken(
'fe81c12a-7336-4aee-bebc-d901a873bf48',
users.tim,
client.id
);
await expectCallbackRedirect(page, client.logoutCallbackUrl, () =>
page.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (
e.message.includes('net::ERR_NAME_NOT_RESOLVED') ||
e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
redirectedCorrectly = true;
} else {
throw e;
}
});
expect(redirectedCorrectly).toBeTruthy();
);
});
test('Successfully refresh tokens with valid refresh token', async ({ request }) => {
@@ -627,17 +588,9 @@ test('Forces reauthentication when client requires it', async ({ page, request }
await (await passkeyUtil.init(page)).addPasskey();
const urlParams = createUrlParams(oidcClients.nextcloud);
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByTestId('scopes')).not.toBeVisible();
await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
await expectCallbackRedirect(page, oidcClients.nextcloud.callbackUrl, async () => {
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByTestId('scopes')).not.toBeVisible();
});
expect(webauthnStartCalled).toBe(true);
@@ -676,9 +629,9 @@ test('Authorize existing client with response_mode=fragment', async ({ page }) =
const urlParams = createUrlParams(oidcClient);
urlParams.set('response_mode', 'fragment');
await page.goto(`/authorize?${urlParams.toString()}`);
const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl);
const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(redirectUrl.search).toBe('');
const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1));
@@ -693,23 +646,6 @@ function waitForFormPostRequest(page: Page, callbackUrl: string): Promise<Reques
);
}
async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL> {
const expectedUrl = new URL(callbackUrl);
await page
.waitForURL((url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname)
.catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
return new URL(page.url());
}
async function expectFormPostRequest(formPostRequestPromise: Promise<Request>) {
const request = await formPostRequestPromise;
const formData = new URLSearchParams(request.postData() ?? '');
@@ -744,9 +680,9 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none');
urlParams.set('response_mode', 'fragment');
await page.goto(`/authorize?${urlParams.toString()}`).then(() => {});
const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl);
const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(redirectUrl.search).toBe('');
const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1));
@@ -775,17 +711,9 @@ test.describe('OIDC prompt parameter', () => {
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'none');
await page.goto(`/authorize?${urlParams.toString()}`);
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
});
test('prompt=consent forces consent display even for authorized client', async ({ page }) => {
@@ -801,17 +729,9 @@ test.describe('OIDC prompt parameter', () => {
).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Should redirect successfully after consent
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
});
test('prompt=login forces reauthentication', async ({ page }) => {
@@ -826,17 +746,9 @@ test.describe('OIDC prompt parameter', () => {
});
await (await passkeyUtil.init(page)).addPasskey();
await page.goto(`/authorize?${urlParams.toString()}`);
// Should require reauthentication even though user is signed in
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(reauthCalled).toBe(true);
});
@@ -853,17 +765,9 @@ test.describe('OIDC prompt parameter', () => {
await expect(selectionCard).toBeVisible();
await expect(selectionCard).toContainText('Tim Cook');
await page.getByRole('button', { name: 'Sign In' }).click();
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign In' }).click()
);
});
test('prompt=select_account account can be changed', async ({ page }) => {
@@ -880,17 +784,9 @@ test.describe('OIDC prompt parameter', () => {
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('button', { name: 'Sign In' }).click();
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign In' }).click()
);
});
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
@@ -935,3 +831,58 @@ test.describe('OIDC prompt parameter', () => {
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
});
async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL> {
const expectedUrl = new URL(callbackUrl);
const isCallbackURL = (url: URL) =>
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
const callbackRequest = await page.waitForRequest((request) =>
isCallbackURL(new URL(request.url()))
);
await page.waitForURL(isCallbackURL, { waitUntil: 'commit' }).catch(() => {});
const currentURL = new URL(page.url());
if (isCallbackURL(currentURL)) {
return currentURL;
}
return new URL(callbackRequest.url());
}
async function expectCallbackRedirect(
page: Page,
callbackUrl: string,
action: () => Promise<unknown>
): Promise<URL> {
const callbackRouteMatcher = await routeCallbackPage(page, callbackUrl);
try {
const callbackURLPromise = waitForCallbackURL(page, callbackUrl);
const actionPromise = action().then(
() => undefined,
(error) => error
);
const callbackURL = await callbackURLPromise;
await actionPromise;
return callbackURL;
} finally {
await page.unroute(callbackRouteMatcher);
}
}
async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url: URL) => boolean> {
const expectedUrl = new URL(callbackUrl);
const callbackRouteMatcher = (url: URL) =>
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
await page.route(callbackRouteMatcher, async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><title>OIDC callback</title>'
});
});
return callbackRouteMatcher;
}