From 27ac83613d57ba81436c784eb0c70bc12635ed55 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 27 Feb 2026 17:37:30 +0100 Subject: [PATCH] test(e2e): automated tests for OIDC --- .github/workflows/e2e.yml | 10 +- .gitignore | 2 + AGENTS.md | 2 - app/client/components/grid-background.tsx | 2 +- .../components/organization-switcher.tsx | 4 +- app/client/components/ui/card.tsx | 2 +- docker-compose.yml | 12 ++ e2e/0002-backup-restore.spec.ts | 108 +++++++---- e2e/0003-oidc.spec.ts | 167 ++++++++++++++++++ package.json | 2 +- playwright.config.ts | 6 +- playwright/dex-config.yaml | 48 +++++ 12 files changed, 316 insertions(+), 49 deletions(-) create mode 100644 e2e/0003-oidc.spec.ts create mode 100644 playwright/dex-config.yaml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f073918d..a8b203f4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -51,6 +51,7 @@ jobs: echo "SERVER_IP=localhost" >> .env.local echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local echo "BASE_URL=http://localhost:4096" >> .env.local + echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local - name: Start zerobyte-e2e service run: bun run start:e2e -- -d @@ -59,9 +60,13 @@ jobs: run: | timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done' + - name: Wait for Dex to be ready + run: | + timeout 30s bash -c 'until curl -sf http://localhost:5557/dex/.well-known/openid-configuration; do echo "Waiting for Dex..." && sleep 2; done' + - name: Print docker logs if failed to start if: failure() - run: docker logs zerobyte || true + run: docker compose logs zerobyte-e2e dex || true - name: Make playwright directory writable run: sudo chmod 777 playwright/data/zerobyte.db @@ -73,7 +78,8 @@ jobs: if: always() run: | tree playwright - docker logs zerobyte > playwright-report/container-logs.txt || true + docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true + docker compose logs dex > playwright-report/dex-logs.txt || true - name: Debug - print content of /test-data in container if: failure() diff --git a/.gitignore b/.gitignore index 1e5f011a..8122a126 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ node_modules/ /blob-report/ /playwright/.cache/ /playwright/.auth/ +/playwright/restic.pass playwright/.auth playwright/temp @@ -39,3 +40,4 @@ openapi-ts-error-*.log .output tmp/ qa-output + diff --git a/AGENTS.md b/AGENTS.md index bebae9ca..2d1f8237 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,3 @@ -# AGENTS.md - ## Important instructions - Never create migration files manually. Always use the provided command to generate migrations diff --git a/app/client/components/grid-background.tsx b/app/client/components/grid-background.tsx index 5c46461e..ec417f3a 100644 --- a/app/client/components/grid-background.tsx +++ b/app/client/components/grid-background.tsx @@ -16,7 +16,7 @@ export function GridBackground({ children, className, containerClassName }: Grid "bg-[size:40px_40px]", "bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", - "[mask-image:radial-gradient(ellipse_at_top,black_40%,transparent_100%)]", + "[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]", )} />
{children}
diff --git a/app/client/components/organization-switcher.tsx b/app/client/components/organization-switcher.tsx index 954a8f01..91f3f0fa 100644 --- a/app/client/components/organization-switcher.tsx +++ b/app/client/components/organization-switcher.tsx @@ -59,7 +59,7 @@ export function OrganizationSwitcher() { size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > -
+
{activeOrganization?.logo ? ( {activeOrganization?.name} {organizations.length} organizations
- + { - const testDataPath = path.join(process.cwd(), "playwright", "temp"); - if (fs.existsSync(testDataPath)) { - for (const file of fs.readdirSync(testDataPath)) { - fs.rmSync(path.join(testDataPath, file), { recursive: true, force: true }); - } - } else { - fs.mkdirSync(testDataPath, { recursive: true }); - } -}); +type ScenarioNames = { + volumeName: string; + repositoryName: string; + backupName: string; +}; -test("can backup & restore a file", async ({ page }) => { - await gotoAndWaitForAppReady(page, "/"); - await expect(page).toHaveURL("/volumes"); +function getRunId(testInfo: TestInfo) { + return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`; +} - // 0. Create a test file in /test-data - const testDataPath = path.join(process.cwd(), "playwright", "temp"); - const filePath = path.join(testDataPath, "test.json"); +function getScenarioNames(runId: string): ScenarioNames { + return { + volumeName: `Volume-${runId}`, + repositoryName: `Repo-${runId}`, + backupName: `Backup-${runId}`, + }; +} + +function prepareTestFile(runId: string): string { + const runPath = path.join(testDataPath, runId); + fs.mkdirSync(runPath, { recursive: true }); + + const filePath = path.join(runPath, "test.json"); fs.writeFileSync(filePath, JSON.stringify({ data: "test file" })); - // 1. Create a local volume on /test-data + return filePath; +} + +async function createBackupScenario(page: Page, names: ScenarioNames) { await page.getByRole("button", { name: "Create Volume" }).click(); - await page.getByRole("textbox", { name: "Name" }).fill("Test Volume"); + await page.getByRole("textbox", { name: "Name" }).fill(names.volumeName); await page.getByRole("button", { name: "test-data" }).click(); await page.getByRole("button", { name: "Create Volume" }).click(); await expect(page.getByText("Volume created successfully")).toBeVisible(); - // 2. Create a local repository on the default location await page.getByRole("link", { name: "Repositories" }).click(); await page.getByRole("button", { name: "Create repository" }).click(); - await page.getByRole("textbox", { name: "Name" }).fill("Test Repo"); + await page.getByRole("textbox", { name: "Name" }).fill(names.repositoryName); await page.getByRole("combobox", { name: "Backend" }).click(); await page.getByRole("option", { name: "Local" }).click(); await page.getByRole("button", { name: "Create repository" }).click(); - await expect(page.getByText("Repository created successfully")).toBeVisible(); + await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 }); - // 3. Create a backup schedule await page.getByRole("link", { name: "Backups" }).click(); - await page.getByRole("button", { name: "Create a backup job" }).click(); + const createBackupButton = page.getByRole("button", { name: "Create a backup job" }).first(); + if (await createBackupButton.isVisible()) { + await createBackupButton.click(); + } else { + await page.getByRole("link", { name: "Create a backup job" }).first().click(); + } await page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" }).click(); - await page.getByRole("option", { name: "Test Volume" }).click(); - await page.getByRole("textbox", { name: "Backup name" }).fill("Test Backup"); + await page.getByRole("option", { name: names.volumeName }).click(); + await page.getByRole("textbox", { name: "Backup name" }).fill(names.backupName); await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click(); - await page.getByRole("option", { name: "Test Repo" }).click(); + await page.getByRole("option", { name: names.repositoryName }).click(); await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click(); await page.getByRole("option", { name: "Daily" }).click(); await page.getByRole("textbox", { name: "Execution time" }).fill("00:00"); await page.getByRole("button", { name: "Create" }).click(); await expect(page.getByText("Backup job created successfully")).toBeVisible(); +} + +test("can backup & restore a file", async ({ page }, testInfo) => { + const runId = getRunId(testInfo); + const names = getScenarioNames(runId); + const filePath = prepareTestFile(runId); + + await gotoAndWaitForAppReady(page, "/"); + await expect(page).toHaveURL("/volumes"); + + await createBackupScenario(page, names); - // 4. Runs that schedule once await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); - // 5. Modify the json file after the backup fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" })); - // 6. Restores the file from backup - await page.getByRole("button", { name: /20 B/ }).click(); + await page + .getByRole("button", { name: /\d+ B$/ }) + .first() + .click(); await page.getByRole("link", { name: "Restore" }).click(); await expect(page).toHaveURL(/\/restore/); await page.getByRole("button", { name: "Restore All" }).click(); await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 }); - // 7. Ensures that the file is back to its previous state const restoredContent = fs.readFileSync(filePath, "utf8"); expect(JSON.parse(restoredContent)).toEqual({ data: "test file" }); }); -test("deleting a volume cascades and removes its backup schedule", async ({ page }) => { - await gotoAndWaitForAppReady(page, "/backups"); - await page.getByText("Test Backup", { exact: true }).first().click(); +test("deleting a volume cascades and removes its backup schedule", async ({ page }, testInfo) => { + const runId = getRunId(testInfo); + const names = getScenarioNames(runId); - const volumeLink = page.locator("main").getByRole("link", { name: "Test Volume", exact: true }).first(); + await gotoAndWaitForAppReady(page, "/"); + await expect(page).toHaveURL("/volumes"); + + await createBackupScenario(page, names); + + await gotoAndWaitForAppReady(page, "/backups"); + await page.getByText(names.backupName, { exact: true }).first().click(); + + const volumeLink = page.locator("main").getByRole("link", { name: names.volumeName, exact: true }).first(); await expect(volumeLink).toBeVisible(); await volumeLink.click(); await expect(page).toHaveURL(/\/volumes\/[^/?#]+/); @@ -95,5 +125,5 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page await expect(page.getByText("Volume deleted successfully")).toBeVisible(); await gotoAndWaitForAppReady(page, "/backups"); - await expect(page.getByText("Test Backup", { exact: true })).toHaveCount(0); + await expect(page.getByText(names.backupName, { exact: true })).toHaveCount(0); }); diff --git a/e2e/0003-oidc.spec.ts b/e2e/0003-oidc.spec.ts new file mode 100644 index 00000000..65fa16a1 --- /dev/null +++ b/e2e/0003-oidc.spec.ts @@ -0,0 +1,167 @@ +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=users"); + 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/); + }); +}); diff --git a/package.json b/package.json index a54efcf2..5811c29b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "tsc": "tsc --noEmit", "start:dev": "docker compose down && docker compose up --build zerobyte-dev", "start:prod": "docker compose down && docker compose up --build zerobyte-prod", - "start:e2e": "docker compose down && docker compose up --build zerobyte-e2e", + "start:e2e": "docker compose down && rm -f playwright/data/zerobyte.db playwright/data/cache.db playwright/data/restic.pass playwright/.auth/user.json playwright/restic.pass && docker compose up --build zerobyte-e2e", "start:distroless": "docker compose down && docker compose up --build zerobyte-distroless", "gen:api-client": "dotenv -e .env.local -- openapi-ts", "gen:migrations": "dotenv -e .env.local -- drizzle-kit generate", diff --git a/playwright.config.ts b/playwright.config.ts index 0acbfcc9..4fe4fcf0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,8 +5,8 @@ export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, + timeout: 90000, retries: 0, - workers: process.env.CI ? 1 : undefined, reporter: "html", use: { baseURL: `http://${process.env.SERVER_IP}:4096`, @@ -17,12 +17,16 @@ export default defineConfig({ { name: "setup", testMatch: /.*\.setup\.ts/, + workers: 1, }, { name: "chromium", use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json", + launchOptions: { + args: ["--host-rules=MAP dex 127.0.0.1"], + }, }, dependencies: ["setup"], }, diff --git a/playwright/dex-config.yaml b/playwright/dex-config.yaml new file mode 100644 index 00000000..d2e43267 --- /dev/null +++ b/playwright/dex-config.yaml @@ -0,0 +1,48 @@ +issuer: http://dex:5557/dex + +storage: + type: sqlite3 + config: + file: /tmp/dex.db + +web: + http: 0.0.0.0:5557 + +enablePasswordDB: true + +staticPasswords: + - email: "admin@example.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + - email: "user@example.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "user" + - email: "test@example.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "test" + - email: "test@test.com" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "test-local" + +staticClients: + - id: zerobyte-test + name: Zerobyte Test + redirectURIs: + - "http://localhost:3000/api/auth/sso/callback/test-oidc" + - "http://localhost:3000/api/auth/sso/callback/test-oidc-register" + - "http://localhost:3000/api/auth/sso/callback/test-oidc-uninvited" + - "http://localhost:3000/api/auth/sso/callback/test-oidc-invited" + - "http://localhost:3000/api/auth/sso/callback/test-oidc-autolink" + - "http://localhost:4096/api/auth/sso/callback/test-oidc" + - "http://localhost:4096/api/auth/sso/callback/test-oidc-register" + - "http://localhost:4096/api/auth/sso/callback/test-oidc-uninvited" + - "http://localhost:4096/api/auth/sso/callback/test-oidc-invited" + - "http://localhost:4096/api/auth/sso/callback/test-oidc-autolink" + secret: test-secret-12345 + +oauth2: + skipApprovalScreen: true + +logger: + level: debug + format: json