From 3b94794aa3e9acd669c0c7a466849a3d38aa0ca0 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 4 Mar 2026 22:33:10 +0100 Subject: [PATCH] test(e2e): more cases for oidc --- e2e/0003-oidc.spec.ts | 218 +++++++++++++++++++++++++++++++++---- playwright/dex-config.yaml | 4 + 2 files changed, 201 insertions(+), 21 deletions(-) diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts index 2dde1c09..a06d37b7 100644 --- a/e2e/0003-oidc.spec.ts +++ b/e2e/0003-oidc.spec.ts @@ -7,19 +7,43 @@ const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`; const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`; const providerIds = { - register: "test-oidc-register", uninvited: "test-oidc-uninvited", invited: "test-oidc-invited", - autoLink: "test-oidc", + autoLinkNoInvite: "test-oidc-register", + autoLink: "test-oidc-autolink", } as const; const dexPassword = "password"; const uninvitedUserEmail = "admin@example.com"; const invitedUserEmail = "user@example.com"; +const autoLinkUninvitedLocalEmail = "linkguard@example.com"; const autoLinkTargetEmail = "test@example.com"; const autoLinkTargetUsername = "sso-link-target"; +const autoLinkUninvitedLocalUsername = "sso-link-guard"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; +const accountLinkRequiredMessage = + "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator."; + +type OrgMembersResponse = { + members: { + id: string; + user: { + email: string; + }; + }[]; +}; + +type SsoSettingsResponse = { + invitations: { + email: string; + status: string; + }[]; +}; + +type SsoSignInResponse = { + url?: string; +}; async function openSsoSettings(page: Page) { await gotoAndWaitForAppReady(page, "/settings?tab=organization"); @@ -57,7 +81,7 @@ async function createPendingInvitation(page: Page, email: string) { } } -async function createAutoLinkTargetUser(page: Page, email: string, username: string) { +async function createLocalUser(page: Page, email: string, username: string) { const response = await page.request.post("/api/auth/admin/create-user", { headers: { Origin: appBaseUrl, @@ -75,10 +99,56 @@ async function createAutoLinkTargetUser(page: Page, email: string, username: str }); if (!response.ok()) { - throw new Error(`Failed to create auto-link target user: ${await response.text()}`); + throw new Error(`Failed to create local user ${email}: ${await response.text()}`); } } +async function getOrgMemberIdByEmail(page: Page, email: string) { + const response = await page.request.get("/api/v1/auth/org-members", { + headers: { + Origin: appBaseUrl, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to get organization members: ${await response.text()}`); + } + + const body = (await response.json()) as OrgMembersResponse; + const member = body.members.find((entry) => entry.user.email.toLowerCase() === email.toLowerCase()); + + return member?.id ?? null; +} + +async function removeOrgMemberById(page: Page, memberId: string) { + const response = await page.request.delete(`/api/v1/auth/org-members/${memberId}`, { + headers: { + Origin: appBaseUrl, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to remove org member ${memberId}: ${await response.text()}`); + } +} + +async function getInvitationStatusByEmail(page: Page, email: string) { + const response = await page.request.get("/api/v1/auth/sso-settings", { + headers: { + Origin: appBaseUrl, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to read SSO settings: ${await response.text()}`); + } + + const body = (await response.json()) as SsoSettingsResponse; + const invitation = body.invitations.find((entry) => entry.email.toLowerCase() === email.toLowerCase()); + + return invitation?.status ?? null; +} + async function setProviderAutoLinking(page: Page, providerId: string, enabled: boolean) { await openSsoSettings(page); const providerRow = page @@ -98,6 +168,31 @@ async function setProviderAutoLinking(page: Page, providerId: string, enabled: b await expect(autoLinkSwitch).toHaveAttribute("aria-checked", expectedState); } +async function startSsoLogin(page: Page, providerId: string) { + const response = await page.request.post("/api/auth/sign-in/sso", { + headers: { + Origin: appBaseUrl, + }, + data: { + providerId, + callbackURL: "/volumes", + errorCallbackURL: "/api/v1/auth/login-error", + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to start SSO login for ${providerId}: ${await response.text()}`); + } + + const body = (await response.json()) as SsoSignInResponse; + + if (!body.url) { + throw new Error(`SSO login response missing redirect URL for ${providerId}`); + } + + return body.url; +} + async function withOidcLoginAttempt( browser: Browser, providerId: string, @@ -113,13 +208,17 @@ async function withOidcLoginAttempt( const page = await context.newPage(); try { - await gotoAndWaitForAppReady(page, `${appBaseUrl}/login`); - await page.getByRole("button", { name: `Log in with ${providerId}`, exact: true }).click(); - await page.waitForURL(/\/dex\/auth/, { timeout: 60000 }); + const ssoUrl = await startSsoLogin(page, providerId); + await page.goto(ssoUrl); - await page.locator('input[name="login"]').fill(dexLogin); - await page.locator('input[name="password"]').fill(dexPassword); - await page.locator('button[type="submit"]').click(); + const dexLoginInput = page.locator('input[name="login"]'); + const dexLoginIsVisible = await dexLoginInput.isVisible({ timeout: 5000 }).catch(() => false); + + if (dexLoginIsVisible) { + await dexLoginInput.fill(dexLogin); + await page.locator('input[name="password"]').fill(dexPassword); + await page.locator('button[type="submit"]').click(); + } await assertions(page); } finally { @@ -127,40 +226,117 @@ async function withOidcLoginAttempt( } } -test("admin can register an OIDC provider", async ({ page }) => { - await registerOidcProvider(page, providerIds.register); -}); +async function expectInviteOnlyLoginError(page: Page) { + await expect + .poll( + () => { + const url = page.url(); + return /\/login(\/error)?/.test(url) || /\/api\/auth\/sso\/callback\//.test(url); + }, + { timeout: 30000 }, + ) + .toBe(true); + + if (/\/login(\/error)?/.test(page.url())) { + await waitForAppReady(page); + await expect(page.getByText(inviteOnlyMessage)).toBeVisible(); + return; + } + + await expect(page.getByText(/invite-only/i)).toBeVisible(); +} + +async function expectAccountLinkRequiredLoginError(page: Page) { + await expect + .poll( + () => { + const url = page.url(); + return /\/login(\/error)?/.test(url) || /\/api\/auth\/sso\/callback\//.test(url); + }, + { timeout: 30000 }, + ) + .toBe(true); + + if (/\/login(\/error)?/.test(page.url())) { + await waitForAppReady(page); + await expect(page.getByText(accountLinkRequiredMessage)).toBeVisible(); + return; + } + + await expect(page.getByText(/account exists but is not linked/i)).toBeVisible(); +} test("uninvited OIDC users are blocked", async ({ page, browser }) => { await registerOidcProvider(page, providerIds.uninvited); await withOidcLoginAttempt(browser, providerIds.uninvited, uninvitedUserEmail, async (ssoPage) => { - await ssoPage.waitForURL(/\/login(\/error)?/, { timeout: 60000 }); - await waitForAppReady(ssoPage); - await expect(ssoPage.getByText(inviteOnlyMessage)).toBeVisible(); + await expectInviteOnlyLoginError(ssoPage); }); }); -test("invited OIDC users can sign in", async ({ page, browser }) => { +test("invited OIDC users can sign in, retain access, and are blocked after removal", async ({ page, browser }) => { await registerOidcProvider(page, providerIds.invited); await createPendingInvitation(page, invitedUserEmail); await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => { - await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 }); + await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 }); await waitForAppReady(ssoPage); await expect(ssoPage).toHaveURL(/\/volumes/); }); + + await expect + .poll(async () => { + return getInvitationStatusByEmail(page, invitedUserEmail); + }) + .toBe("accepted"); + + await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => { + await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 }); + await waitForAppReady(ssoPage); + await expect(ssoPage).toHaveURL(/\/volumes/); + }); + + await expect + .poll(async () => { + return getOrgMemberIdByEmail(page, invitedUserEmail); + }) + .not.toBeNull(); + + const memberId = await getOrgMemberIdByEmail(page, invitedUserEmail); + + if (!memberId) { + throw new Error(`Missing organization member for ${invitedUserEmail}`); + } + + await removeOrgMemberById(page, memberId); + + await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => { + await expectInviteOnlyLoginError(ssoPage); + }); }); -test("auto-link setting can be enabled for an OIDC provider", async ({ page, browser }) => { +test("auto-link policy enforces invitation and controls account linking", async ({ page, browser }) => { + await registerOidcProvider(page, providerIds.autoLinkNoInvite); + await createLocalUser(page, autoLinkUninvitedLocalEmail, autoLinkUninvitedLocalUsername); + await setProviderAutoLinking(page, providerIds.autoLinkNoInvite, true); + + await withOidcLoginAttempt(browser, providerIds.autoLinkNoInvite, autoLinkUninvitedLocalEmail, async (ssoPage) => { + await expectInviteOnlyLoginError(ssoPage); + }); + await registerOidcProvider(page, providerIds.autoLink); - await createAutoLinkTargetUser(page, autoLinkTargetEmail, autoLinkTargetUsername); + await createLocalUser(page, autoLinkTargetEmail, autoLinkTargetUsername); await createPendingInvitation(page, autoLinkTargetEmail); + await setProviderAutoLinking(page, providerIds.autoLink, false); + + await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => { + await expectAccountLinkRequiredLoginError(ssoPage); + }); await setProviderAutoLinking(page, providerIds.autoLink, true); await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => { - await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 }); + await ssoPage.waitForURL(/\/volumes/, { timeout: 30000 }); await waitForAppReady(ssoPage); await expect(ssoPage).toHaveURL(/\/volumes/); }); diff --git a/playwright/dex-config.yaml b/playwright/dex-config.yaml index 91af1066..722148bf 100644 --- a/playwright/dex-config.yaml +++ b/playwright/dex-config.yaml @@ -27,6 +27,10 @@ staticPasswords: hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "test-local" userID: "004" + - email: "linkguard@example.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "linkguard" + userID: "005" staticClients: - id: zerobyte-test