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