test(e2e): automated tests for OIDC
This commit is contained in:
parent
b49f639044
commit
27ac83613d
12 changed files with 316 additions and 49 deletions
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
|
|
@ -51,6 +51,7 @@ jobs:
|
||||||
echo "SERVER_IP=localhost" >> .env.local
|
echo "SERVER_IP=localhost" >> .env.local
|
||||||
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
||||||
echo "BASE_URL=http://localhost:4096" >> .env.local
|
echo "BASE_URL=http://localhost:4096" >> .env.local
|
||||||
|
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
||||||
|
|
||||||
- name: Start zerobyte-e2e service
|
- name: Start zerobyte-e2e service
|
||||||
run: bun run start:e2e -- -d
|
run: bun run start:e2e -- -d
|
||||||
|
|
@ -59,9 +60,13 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
|
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
|
- name: Print docker logs if failed to start
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker logs zerobyte || true
|
run: docker compose logs zerobyte-e2e dex || true
|
||||||
|
|
||||||
- name: Make playwright directory writable
|
- name: Make playwright directory writable
|
||||||
run: sudo chmod 777 playwright/data/zerobyte.db
|
run: sudo chmod 777 playwright/data/zerobyte.db
|
||||||
|
|
@ -73,7 +78,8 @@ jobs:
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
tree playwright
|
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
|
- name: Debug - print content of /test-data in container
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -28,6 +28,7 @@ node_modules/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
|
/playwright/restic.pass
|
||||||
|
|
||||||
playwright/.auth
|
playwright/.auth
|
||||||
playwright/temp
|
playwright/temp
|
||||||
|
|
@ -39,3 +40,4 @@ openapi-ts-error-*.log
|
||||||
.output
|
.output
|
||||||
tmp/
|
tmp/
|
||||||
qa-output
|
qa-output
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Important instructions
|
## Important instructions
|
||||||
|
|
||||||
- Never create migration files manually. Always use the provided command to generate migrations
|
- Never create migration files manually. Always use the provided command to generate migrations
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
||||||
"bg-[size:40px_40px]",
|
"bg-[size:40px_40px]",
|
||||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
"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)]",
|
"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%)]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
|
||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg">
|
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||||
{activeOrganization?.logo ? (
|
{activeOrganization?.logo ? (
|
||||||
<img
|
<img
|
||||||
src={activeOrganization.logo}
|
src={activeOrganization.logo}
|
||||||
|
|
@ -74,7 +74,7 @@ export function OrganizationSwitcher() {
|
||||||
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
||||||
<span className="truncate text-xs">{organizations.length} organizations</span>
|
<span className="truncate text-xs">{organizations.length} organizations</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto" />
|
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border/80 py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,25 @@ services:
|
||||||
- DISABLE_RATE_LIMITING=true
|
- DISABLE_RATE_LIMITING=true
|
||||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||||
- BASE_URL=http://localhost:4096
|
- BASE_URL=http://localhost:4096
|
||||||
|
- TRUSTED_ORIGINS=http://dex:5557,http://localhost:5557
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_ADMIN
|
- SYS_ADMIN
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
|
depends_on:
|
||||||
|
- dex
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- ./playwright/data:/var/lib/zerobyte/data
|
- ./playwright/data:/var/lib/zerobyte/data
|
||||||
- ./playwright/temp:/test-data
|
- ./playwright/temp:/test-data
|
||||||
|
|
||||||
|
dex:
|
||||||
|
image: ghcr.io/dexidp/dex:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5557:5557"
|
||||||
|
volumes:
|
||||||
|
- ./playwright/dex-config.yaml:/etc/dex/cfg.yaml:ro
|
||||||
|
command: dex serve /etc/dex/cfg.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,115 @@
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||||
import { gotoAndWaitForAppReady } from "./helpers/page";
|
import { gotoAndWaitForAppReady } from "./helpers/page";
|
||||||
|
|
||||||
test.describe.configure({ mode: "serial" });
|
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
||||||
|
|
||||||
test.beforeAll(() => {
|
type ScenarioNames = {
|
||||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
volumeName: string;
|
||||||
if (fs.existsSync(testDataPath)) {
|
repositoryName: string;
|
||||||
for (const file of fs.readdirSync(testDataPath)) {
|
backupName: string;
|
||||||
fs.rmSync(path.join(testDataPath, file), { recursive: true, force: true });
|
};
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.mkdirSync(testDataPath, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can backup & restore a file", async ({ page }) => {
|
function getRunId(testInfo: TestInfo) {
|
||||||
await gotoAndWaitForAppReady(page, "/");
|
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
||||||
await expect(page).toHaveURL("/volumes");
|
}
|
||||||
|
|
||||||
// 0. Create a test file in /test-data
|
function getScenarioNames(runId: string): ScenarioNames {
|
||||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
return {
|
||||||
const filePath = path.join(testDataPath, "test.json");
|
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" }));
|
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("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: "test-data" }).click();
|
||||||
await page.getByRole("button", { name: "Create Volume" }).click();
|
await page.getByRole("button", { name: "Create Volume" }).click();
|
||||||
await expect(page.getByText("Volume created successfully")).toBeVisible();
|
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("link", { name: "Repositories" }).click();
|
||||||
await page.getByRole("button", { name: "Create repository" }).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("combobox", { name: "Backend" }).click();
|
||||||
await page.getByRole("option", { name: "Local" }).click();
|
await page.getByRole("option", { name: "Local" }).click();
|
||||||
await page.getByRole("button", { name: "Create repository" }).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("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("combobox").filter({ hasText: "Choose a volume to backup" }).click();
|
||||||
await page.getByRole("option", { name: "Test Volume" }).click();
|
await page.getByRole("option", { name: names.volumeName }).click();
|
||||||
await page.getByRole("textbox", { name: "Backup name" }).fill("Test Backup");
|
await page.getByRole("textbox", { name: "Backup name" }).fill(names.backupName);
|
||||||
await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click();
|
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("combobox").filter({ hasText: "Select frequency" }).click();
|
||||||
await page.getByRole("option", { name: "Daily" }).click();
|
await page.getByRole("option", { name: "Daily" }).click();
|
||||||
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
|
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
|
||||||
await page.getByRole("button", { name: "Create" }).click();
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
await expect(page.getByText("Backup job created successfully")).toBeVisible();
|
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 page.getByRole("button", { name: "Backup now" }).click();
|
||||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||||
await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 });
|
await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
// 5. Modify the json file after the backup
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
|
fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
|
||||||
|
|
||||||
// 6. Restores the file from backup
|
await page
|
||||||
await page.getByRole("button", { name: /20 B/ }).click();
|
.getByRole("button", { name: /\d+ B$/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await page.getByRole("link", { name: "Restore" }).click();
|
await page.getByRole("link", { name: "Restore" }).click();
|
||||||
await expect(page).toHaveURL(/\/restore/);
|
await expect(page).toHaveURL(/\/restore/);
|
||||||
await page.getByRole("button", { name: "Restore All" }).click();
|
await page.getByRole("button", { name: "Restore All" }).click();
|
||||||
await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 });
|
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");
|
const restoredContent = fs.readFileSync(filePath, "utf8");
|
||||||
expect(JSON.parse(restoredContent)).toEqual({ data: "test file" });
|
expect(JSON.parse(restoredContent)).toEqual({ data: "test file" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deleting a volume cascades and removes its backup schedule", async ({ page }) => {
|
test("deleting a volume cascades and removes its backup schedule", async ({ page }, testInfo) => {
|
||||||
await gotoAndWaitForAppReady(page, "/backups");
|
const runId = getRunId(testInfo);
|
||||||
await page.getByText("Test Backup", { exact: true }).first().click();
|
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 expect(volumeLink).toBeVisible();
|
||||||
await volumeLink.click();
|
await volumeLink.click();
|
||||||
await expect(page).toHaveURL(/\/volumes\/[^/?#]+/);
|
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 expect(page.getByText("Volume deleted successfully")).toBeVisible();
|
||||||
|
|
||||||
await gotoAndWaitForAppReady(page, "/backups");
|
await gotoAndWaitForAppReady(page, "/backups");
|
||||||
await expect(page.getByText("Test Backup", { exact: true })).toHaveCount(0);
|
await expect(page.getByText(names.backupName, { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
167
e2e/0003-oidc.spec.ts
Normal file
167
e2e/0003-oidc.spec.ts
Normal file
|
|
@ -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<void>,
|
||||||
|
) {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"tsc": "tsc --noEmit",
|
"tsc": "tsc --noEmit",
|
||||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
"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",
|
"start:distroless": "docker compose down && docker compose up --build zerobyte-distroless",
|
||||||
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
|
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
|
||||||
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
|
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
timeout: 90000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
use: {
|
use: {
|
||||||
baseURL: `http://${process.env.SERVER_IP}:4096`,
|
baseURL: `http://${process.env.SERVER_IP}:4096`,
|
||||||
|
|
@ -17,12 +17,16 @@ export default defineConfig({
|
||||||
{
|
{
|
||||||
name: "setup",
|
name: "setup",
|
||||||
testMatch: /.*\.setup\.ts/,
|
testMatch: /.*\.setup\.ts/,
|
||||||
|
workers: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
storageState: "playwright/.auth/user.json",
|
storageState: "playwright/.auth/user.json",
|
||||||
|
launchOptions: {
|
||||||
|
args: ["--host-rules=MAP dex 127.0.0.1"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
48
playwright/dex-config.yaml
Normal file
48
playwright/dex-config.yaml
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue