import { expect, test, type Browser, type Page } from "@playwright/test"; import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page"; const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557"; const issuer = `${dexOrigin}/dex`; 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", } as const; const dexPassword = "password"; const uninvitedUserEmail = "admin@example.com"; const invitedUserEmail = "user@example.com"; const autoLinkTargetEmail = "test@example.com"; const autoLinkTargetUsername = "sso-link-target"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; async function openSsoSettings(page: Page) { await gotoAndWaitForAppReady(page, "/settings?tab=organization"); await expect(page.getByText("Single Sign-On")).toBeVisible(); } async function registerOidcProvider(page: Page, providerId: string) { await gotoAndWaitForAppReady(page, "/settings/sso/new"); await page.getByRole("textbox", { name: "Provider ID" }).fill(providerId); await page.getByRole("textbox", { name: "Organization Domain" }).fill("example.com"); await page.getByRole("textbox", { name: "Issuer URL" }).fill(issuer); await page.getByRole("textbox", { name: "Discovery Endpoint" }).fill(discoveryEndpoint); await page.getByRole("textbox", { name: "Client ID" }).fill("zerobyte-test"); await page.getByRole("textbox", { name: "Client Secret" }).fill("test-secret-12345"); await page.getByRole("button", { name: "Register Provider" }).click(); await expect(page.getByText("SSO provider registered successfully")).toBeVisible(); await expect(page.getByRole("cell", { name: providerId, exact: true })).toBeVisible(); } async function createPendingInvitation(page: Page, email: string) { const response = await page.request.post("/api/auth/organization/invite-member", { headers: { Origin: appBaseUrl, }, data: { email, role: "member", }, }); if (!response.ok()) { throw new Error(`Failed to invite ${email}: ${await response.text()}`); } } async function createAutoLinkTargetUser(page: Page, email: string, username: string) { const response = await page.request.post("/api/auth/admin/create-user", { headers: { Origin: appBaseUrl, }, data: { email, password: dexPassword, name: "SSO Link Target", role: "user", data: { username, hasDownloadedResticPassword: true, }, }, }); if (!response.ok()) { throw new Error(`Failed to create auto-link target user: ${await response.text()}`); } } async function setProviderAutoLinking(page: Page, providerId: string, enabled: boolean) { await openSsoSettings(page); const providerRow = page .getByRole("row") .filter({ has: page.getByRole("cell", { name: providerId, exact: true }) }) .first(); const autoLinkSwitch = providerRow.getByRole("switch"); const expectedState = enabled ? "true" : "false"; const currentState = await autoLinkSwitch.getAttribute("aria-checked"); const expectedToast = enabled ? "Automatic account linking enabled" : "Automatic account linking disabled"; if (currentState !== expectedState) { await autoLinkSwitch.click(); await expect(page.getByText(expectedToast)).toBeVisible(); } await expect(autoLinkSwitch).toHaveAttribute("aria-checked", expectedState); } async function withOidcLoginAttempt( browser: Browser, providerId: string, dexLogin: string, assertions: (page: Page) => Promise, ) { const context = await browser.newContext({ storageState: { cookies: [], origins: [], }, }); 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 }); await page.locator('input[name="login"]').fill(dexLogin); await page.locator('input[name="password"]').fill(dexPassword); await page.locator('button[type="submit"]').click(); await assertions(page); } finally { await context.close().catch(() => undefined); } } test("admin can register an OIDC provider", async ({ page }) => { await registerOidcProvider(page, providerIds.register); }); 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(); }); }); test("invited OIDC users can sign in", 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 waitForAppReady(ssoPage); await expect(ssoPage).toHaveURL(/\/volumes/); }); }); test("auto-link setting can be enabled for an OIDC provider", async ({ page, browser }) => { await registerOidcProvider(page, providerIds.autoLink); await createAutoLinkTargetUser(page, autoLinkTargetEmail, autoLinkTargetUsername); await createPendingInvitation(page, autoLinkTargetEmail); await setProviderAutoLinking(page, providerIds.autoLink, true); await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => { await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 }); await waitForAppReady(ssoPage); await expect(ssoPage).toHaveURL(/\/volumes/); }); });