Skip to content

Commit bf80187

Browse files
committed
fix(integration): bypass flaky LXD SSO button click
1 parent b46a47e commit bf80187

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

tests/integration/assertions/browser.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
isRetryableBlankNavigationError,
1111
isLoginOrOauthCallbackPlumbingPath,
1212
isTargetApplicationPage,
13-
isTargetLoginOrOauthPlumbingPage
13+
isTargetLoginOrOauthPlumbingPage,
14+
lxdOidcLoginUrlForSsoPage
1415
} from "./browser";
1516

1617
describe("browser assertion helpers", () => {
@@ -49,6 +50,24 @@ describe("browser assertion helpers", () => {
4950
expect(isIdentityLoginInputPage("https://app.example.test/protected", "app.example.test")).toBe(true);
5051
});
5152

53+
test("derives the direct LXD OIDC login endpoint from the SSO page", () => {
54+
expect(
55+
lxdOidcLoginUrlForSsoPage(
56+
"https://lxd.example.test/ui/login",
57+
"Canonical LXD\nLogin with SSO\nSet up TLS login",
58+
"lxd.example.test"
59+
)
60+
).toBe("https://lxd.example.test/oidc/login");
61+
expect(lxdOidcLoginUrlForSsoPage("https://lxd.example.test/ui/login", "Canonical LXD", "lxd.example.test")).toBeUndefined();
62+
expect(
63+
lxdOidcLoginUrlForSsoPage(
64+
"https://auth.example.test/ui/v2/login/loginname",
65+
"Canonical LXD\nLogin with SSO",
66+
"lxd.example.test"
67+
)
68+
).toBeUndefined();
69+
});
70+
5271
test("makes browser artifact names distinct by URL, expectation, user, and outcome", () => {
5372
const allowPath = browserScreenshotPath("/tmp/out", "https://app.example.test/protected", "allowed@example.test", "allow", "success");
5473
const denyPath = browserScreenshotPath("/tmp/out", "https://app.example.test/protected", "denied@example.test", "deny", "success");

tests/integration/assertions/browser.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,43 @@ async function inputVisible(page: Page, selectors: readonly string[]): Promise<b
157157
return false;
158158
}
159159

160+
async function firstIdentityInput(page: Page, selectors: readonly string[], targetHost: string): Promise<string> {
161+
const deadline = Date.now() + BROWSER_WAIT_TIMEOUT_MS;
162+
let lastBody = "";
163+
164+
while (Date.now() < deadline) {
165+
const currentUrl = page.url();
166+
if (isTargetLoginOrOauthPlumbingPage(currentUrl, targetHost)) {
167+
await clickOidcStartIfNeeded(page, targetHost);
168+
}
169+
170+
if (isIdentityLoginInputPage(page.url(), targetHost)) {
171+
for (const selector of selectors) {
172+
const locator = page.locator(selector).first();
173+
if (await locatorVisible(locator)) {
174+
return selector;
175+
}
176+
}
177+
}
178+
179+
if (await reloadBlankLoginDocumentIfNeeded(page)) {
180+
await page.waitForTimeout(1000);
181+
continue;
182+
}
183+
184+
lastBody = (await maybeWithTimeout(page.locator("body").innerText({ timeout: 1000 }).catch(() => ""), 2000)) ?? "";
185+
await page.waitForTimeout(500);
186+
}
187+
188+
throw new Error(
189+
[
190+
`none of the identity selectors were visible: ${selectors.join(", ")}`,
191+
`current URL: ${page.url()}`,
192+
`body:\n${bodySnippetForError(lastBody) || "<empty>"}`
193+
].join("\n")
194+
);
195+
}
196+
160197
async function clickOidcStartIfNeeded(page: Page, targetHost: string): Promise<void> {
161198
const deadline = Date.now() + BROWSER_OIDC_START_TIMEOUT_MS;
162199
let reloadedBlankTargetLoginPage = false;
@@ -184,6 +221,14 @@ async function clickOidcStartIfNeeded(page: Page, targetHost: string): Promise<v
184221
continue;
185222
}
186223

224+
const lxdOidcLoginUrl = lxdOidcLoginUrlForSsoPage(page.url(), lastBody, targetHost);
225+
if (lxdOidcLoginUrl && Date.now() - lastStartClick > BROWSER_OIDC_RECLICK_INTERVAL_MS) {
226+
lastStartClick = Date.now();
227+
await maybeWithTimeout(page.goto(lxdOidcLoginUrl, { waitUntil: "commit", timeout: BROWSER_CLICK_TIMEOUT_MS }).catch(() => undefined), 12000);
228+
await waitForOidcStartHandoff(page, targetHost);
229+
continue;
230+
}
231+
187232
const startSelectors = [
188233
'button:has-text("Login with SSO")',
189234
'a:has-text("Login with SSO")',
@@ -801,7 +846,7 @@ async function loginThroughZitadelWithBrowser(
801846
stage = "waiting for identity login document";
802847
await waitForIdentityLoginDocument(page, targetHost);
803848
stage = "waiting for username input";
804-
const emailSelector = await firstVisible(page, [...USERNAME_INPUT_SELECTORS]);
849+
const emailSelector = await firstIdentityInput(page, USERNAME_INPUT_SELECTORS, targetHost);
805850
stage = "entering username";
806851
await typeInto(page, emailSelector, user.email);
807852
stage = "waiting for username submit";
@@ -1180,6 +1225,20 @@ export function isIdentityLoginInputPage(currentUrl: string, targetHost: string)
11801225
return Boolean(parsed && !(parsed.host === targetHost && isLoginOrOauthCallbackPlumbingPath(parsed.pathname)));
11811226
}
11821227

1228+
export function lxdOidcLoginUrlForSsoPage(currentUrl: string, body: string, targetHost: string): string | undefined {
1229+
const parsed = parseBrowserUrl(currentUrl);
1230+
if (!parsed || parsed.host !== targetHost || !parsed.pathname.toLowerCase().startsWith("/ui/login")) {
1231+
return undefined;
1232+
}
1233+
1234+
const normalizedBody = body.toLowerCase();
1235+
if (!normalizedBody.includes("login with sso") || !normalizedBody.includes("canonical lxd")) {
1236+
return undefined;
1237+
}
1238+
1239+
return `${parsed.origin}/oidc/login`;
1240+
}
1241+
11831242
export function formatDeniedTargetRouteFailure(finalUrl: string, body: string): string {
11841243
return [
11851244
"expected denied protected route to show a denial page, but the browser reached the target host without denial text",

0 commit comments

Comments
 (0)