Compare commits

...
Author SHA1 Message Date
killian-larcher 456278d2d9 feat: add http smoke tests. 2026-06-19 12:19:57 +02:00
killian-larcher f2b7e4bfff feat: add http smoke tests. 2026-06-14 17:58:17 +02:00
killian-larcher 25689b6e0d chore: remove all e2e files. 2026-06-14 17:53:56 +02:00
55 changed files with 5602 additions and 4090 deletions
+1
View File
@@ -72,6 +72,7 @@ AUTH_PASSKEY_ENABLED=true
# Retention
RETENTION_CRON="* * * * *"
AUDIT_LOG_RETENTION_DAYS=180
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
-8
View File
@@ -48,14 +48,6 @@ seeds/pocket-id/*.db
certificates
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
+2 -57
View File
@@ -3,60 +3,5 @@ CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
up:
@bash $(CLUSTER_SCRIPT)
seed-keycloak:
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml up -d keycloak
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
seed-pocket:
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
seed-auth: seed-keycloak seed-pocket
pocket-token:
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
export-pocket:
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
@mkdir -p ./seeds/pocket-id
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
export-keycloak:
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
@mkdir -p ./seeds/keycloak
@rm -f ./seeds/keycloak/*.json
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
@docker rm -f kc-exporter >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
@docker rm -f kc-exporter >/dev/null 2>&1
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
end-to-end:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
e2e-manual:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
api-test:
pnpm test:api
-37
View File
@@ -1,37 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-e2e-data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
ports:
- "5433:5432"
volumes:
- postgres-e2e-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
volumes:
portabase-e2e-data:
postgres-e2e-data:
-74
View File
@@ -1,74 +0,0 @@
import {expect, test} from "@playwright/test";
import {users} from "./helpers/auth";
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const firstOrganization = "Organization A";
const secondOrganization = "Organization B";
test.describe.serial(() => {
test("Create an organization from organizations page", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
await create(page, "button", firstOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/admin/organizations");
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Create an organization from sidebar button", async ({page}) => {
await page.goto("/dashboard/home");
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
await create(page, "sidebar", secondOrganization);
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
await expect(page).toHaveURL("/dashboard/home");
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
await switchToDefault(page);
});
test("Change John Doe's role from pending to user", async ({page}) => {
await page.goto("/dashboard/admin/users");
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
await expect(userRow).toBeVisible();
await userRow.locator("button").last().click();
await page.getByRole('menuitem', {name: 'Role'}).click();
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
await changeUserRole(page, "user")
await expect(page.getByText("User role changed successfully.")).toBeVisible();
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
});
test("Add John Doe to Organization A", async ({page}) => {
await page.goto("/dashboard/admin/organizations");
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
await expect(organizationRow).toBeVisible();
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
await page.getByRole("button", {name: /Add member/i}).click();
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
await page.getByRole("button", {name: "Confirm"}).click();
await expect(page.getByText("Member successfully added!")).toBeVisible();
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
});
});
-116
View File
@@ -1,116 +0,0 @@
import {expect, test} from "@playwright/test";
import {execSync} from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
import {create, edit, get, remove} from "./helpers/agent";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const agent = {
aName: "Agent A",
aUpdatedName: "Agent A Updated",
bName: "Agent B",
description: "Agent created by Playwright E2E",
updatedDescription: "Agent updated by Playwright E2E",
};
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe.serial(() => {
let agentWorkspace: string | null = null;
test("Create agent A from empty state", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
await create(page, "emptyState", agent.aName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
});
test("Edit agent A", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.aName)).toBeVisible();
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
await expect(page.getByText("Success updating agent")).toBeVisible();
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
});
test("Create agent B from classic button", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
await create(page, "button", agent.bName, agent.description);
await expect(page.getByText("Success creating agent")).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
});
test("Delete Agent B", async ({page}) => {
await page.goto("/dashboard/agents");
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
await expect(get(page, agent.bName)).toBeVisible();
await remove(page, agent.bName);
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
await expect(page).toHaveURL("/dashboard/agents");
await expect(page.getByText(agent.bName)).toHaveCount(0);
});
// test("Launch the updated agent", async ({page}) => {
// await page.goto("/dashboard/agents");
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
// await get(page, agent.aName).click();
//
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
// await expect(page.getByText("Registration & Setup")).toBeVisible();
//
// const commandInput = page.locator("input[readonly]").first();
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
// const command = await commandInput.inputValue();
//
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
// await createAgentWithDockerDatabases(command, agentWorkspace);
// execSync(`portabase start "${agent.aName}"`, {
// cwd: agentWorkspace,
// stdio: "pipe",
// timeout: 120_000,
// });
//
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
// await expect(page.getByText("Action Required")).toHaveCount(0);
// });
test.afterAll(async () => {
if (agentWorkspace) {
try {
execSync(`portabase stop "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
try {
execSync(`portabase uninstall --force "${agent.aName}"`, {
cwd: agentWorkspace,
stdio: "pipe",
timeout: 30_000,
});
} catch {
}
fs.rmSync(agentWorkspace, {recursive: true, force: true});
agentWorkspace = null;
}
});
});
-104
View File
@@ -1,104 +0,0 @@
import {test, expect} from '@playwright/test';
import {login, register, users} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
const TIMEOUT = undefined
// const TIMEOUT = 5000
test.use({storageState: LOCAL_STORAGE_PATH})
test.describe.serial( () => {
test('Redirect to login if not connected', async ({page}) => {
await page.goto('/dashboard/projects');
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
});
test('Password too short', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '123456'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Must have at least 8 character')
await expect(toast).toBeVisible()
})
test('Password too simple', async ({page}) => {
await page.goto('')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = '12345678'
await register(page, users["admin"].username, users["admin"].email, password, password)
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
await expect(toast).toBeVisible()
})
test('Password and confirm password mismatch', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
let password = 'testPASS123456!'
let confirmPassword = 'testPASS123456!!'
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
const toast = page.locator('text=The passwords did not match')
await expect(toast).toBeVisible()
})
test('Successful register for admin', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('User already exists.', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
const toast = page.locator('text=User already exists. Use another email.')
await expect(toast).toBeVisible()
})
test('Successful register for normal', async ({page}) => {
await page.goto('/')
await page.click('text=Sign up')
await expect(page).toHaveURL('/register')
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
})
test('Failed login because account not active', async ({page}) => {
await page.goto('/login')
await login(page, users["normal"].email, users["normal"].password)
const toast = page.locator('text=Your account is not active.')
await expect(toast).toBeVisible()
})
test('Successful login', async ({page}) => {
await page.goto('/login')
await login(page, users["admin"].email, users["admin"].password)
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
await page.context().storageState({path: LOCAL_STORAGE_PATH})
})
})
-32
View File
@@ -1,32 +0,0 @@
import {expect, test} from "@playwright/test";
import fs from "fs";
import {logout} from "./helpers/auth";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
test.describe( () => {
test.afterAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
fs.unlinkSync(LOCAL_STORAGE_PATH);
}
});
test("Remove shared storage state", async ({page}) => {
await test.step("Logout shared authenticated session", async () => {
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
return;
}
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
if (!content || content === "{}") {
return;
}
await page.goto('/dashboard/home');
await logout(page);
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
});
});
});
-70
View File
@@ -1,70 +0,0 @@
import {Page} from "@playwright/test";
export function getUserRow(page: Page, email: string) {
return page.locator("tr").filter({hasText: email}).first();
}
export function getOrganizationRow(page: Page, organizationName: string) {
return page.locator("tr").filter({hasText: organizationName}).first();
}
/**
* Create an organization from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the organizations page create button.
* - `sidebar`: the sidebar organization switcher.
*
* Executes from:
* - `/dashboard/admin/organizations` for "button" entrypoint
* - any `/dashboard/**` page for "sidebar" entrypoint
*/
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
if (entrypoint === "sidebar") {
await page.getByRole("button", {name: "Default Organization"}).click();
await page.getByText("Create organization", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create a new organization/i}).click();
}
await page.getByLabel("Name").fill(name);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Switch the active organization from the sidebar switcher.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchTo(page: Page, name: string) {
await page.getByTestId('organization-dropdown').click();
await page.getByRole('menuitem', {name: name}).click();
}
/**
* Switch back to the default organization.
*
* Executes from: any `/dashboard/**` if the sidebar is visible.
*/
export async function switchToDefault(page: Page) {
await switchTo(page, "Default Organization");
}
const ROLE_LABELS = {
pending: "Pending",
user: "User",
admin: "Admin",
} as const;
/**
* Change the global role of a user from the admin users role modal.
*
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
*/
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
const roleOption = ROLE_LABELS[role];
await page.getByRole("combobox").click();
await page.getByRole("option", {name: roleOption}).click();
await page.getByRole("button", {name: "Validate"}).click();
}
-107
View File
@@ -1,107 +0,0 @@
import * as pty from "node-pty";
type InteractiveStep = {
match: RegExp;
reply: string;
};
function normalizeOutput(output: string) {
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
}
/**
* Run an interactive CLI command and answer prompts in sequence.
* Entry point:
* - `command` is the full CLI command executed in the PTY.
* - `cwd` is the local workspace where the command is launched.
* - `steps` defines which prompt text is matched and which reply is sent.
* - `timeoutMs` controls when the command is aborted if it stalls.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function runInteractiveCommand(
command: string,
cwd: string,
steps: InteractiveStep[],
timeoutMs: number = 120_000,
) {
await new Promise<void>((resolve, reject) => {
const child = pty.spawn("sh", ["-lc", command], {
cwd,
env: {
...process.env,
TERM: "xterm-256color",
},
cols: 120,
rows: 30,
});
let currentStep = 0;
let output = "";
const timer = setTimeout(() => {
child.kill();
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
}, timeoutMs);
const handleChunk = (chunk: string) => {
output += chunk;
const normalizedOutput = normalizeOutput(output);
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
child.write(steps[currentStep].reply);
currentStep += 1;
}
};
child.onData(handleChunk);
child.onExit(({exitCode}) => {
clearTimeout(timer);
if (exitCode === 0) {
resolve();
return;
}
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
});
});
}
/**
* Create and configure a Portabase agent with two Docker-backed databases.
* Entry point:
* - `command` is the CLI setup command copied from the agent details page.
* - `cwd` is the local workspace where the agent is installed.
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
*/
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
await runInteractiveCommand(command, cwd, [
{
match: /configure.*database|add.*database/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "y\n",
},
{
match: /docker.*new local container|manual.*external|external.*existing/i,
reply: "\r",
},
{
match: /postgresql|mariadb/i,
reply: "\u001B[B\r",
},
{
match: /another.*database|add.*another|configure.*another/i,
reply: "n\n",
},
]);
}
-66
View File
@@ -1,66 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate an agent card in the list.
* Executes from: `/dashboard/agents`.
*/
export function get(page: Page, name: string) {
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
}
/**
* Create an agent from the selected entrypoint.
*
* Available entrypoints:
* - `button`: the classic create button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/agents`.
*/
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
if (entrypoint === "auto") {
const createButton = page.getByRole("button", {name: /Create Agent/i});
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
await page.getByText("Create new Agent", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Create Agent/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("Create new Agent", {exact: true}).click();
}
await page.getByLabel("Name").fill(agentName);
await page.getByLabel("Description").fill(description);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing agent (name and description) from its details page.
*
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Agent/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByLabel("Description").fill(updatedDescription);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an agent from its details page.
* Executes from: `/dashboard/agents/[agentId]`.
*/
export async function remove(page: Page, name: string) {
await get(page, name).click();
await page.getByRole("button", {name: /Delete Agent/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-50
View File
@@ -1,50 +0,0 @@
import {Page} from "@playwright/test";
export type UserCredentials = {
username: string
email: string
password: string
}
export const users: Record<string, UserCredentials> = {
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
}
/**
* Fill and submit the registration form.
*
* Executes from: `/register`.
*/
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
await page.locator('input[name="name"]').fill(name)
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
await page.click('button[type="submit"]')
}
/**
* Fill and submit the login form.
*
* Executes from: `/login`.
*/
export async function login(page: Page, email: string, password: string) {
await page.locator('input[name="email"]').fill(email)
await page.locator('input[name="password"]').fill(password)
await page.locator('button:has-text("Login")').click()
}
/**
* Log out the current authenticated user.
*
* Executes from: any authenticated `/dashboard/**` page.
*/
export async function logout(page: Page) {
const profileButton = page.getByTestId('profile-dropdown')
await profileButton.first().click();
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
}
-62
View File
@@ -1,62 +0,0 @@
const REQUIRED_E2E_ENV_VARS = [
"E2E_NOTIFICATION_SMTP_HOST",
"E2E_NOTIFICATION_SMTP_PORT",
"E2E_NOTIFICATION_SMTP_USER",
"E2E_NOTIFICATION_SMTP_PASSWORD",
"E2E_NOTIFICATION_SMTP_FROM",
"E2E_NOTIFICATION_SMTP_TO",
"E2E_NOTIFICATION_SLACK_WEBHOOK",
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
"E2E_NOTIFICATION_NTFY_TOPIC",
"E2E_NOTIFICATION_NTFY_SERVER_URL",
"E2E_NOTIFICATION_NTFY_TOKEN",
"E2E_NOTIFICATION_NTFY_USERNAME",
"E2E_NOTIFICATION_NTFY_PASSWORD",
"E2E_NOTIFICATION_WEBHOOK_URL",
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
"E2E_NOTIFICATION_WEBHOOK_SECRET",
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
"E2E_STORAGE_AWS_S3_REGION",
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
"E2E_STORAGE_AWS_S3_SECRET_KEY",
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
"E2E_STORAGE_AWS_S3_PORT",
"E2E_STORAGE_R2_ENDPOINT_URL",
"E2E_STORAGE_R2_REGION",
"E2E_STORAGE_R2_ACCESS_KEY",
"E2E_STORAGE_R2_SECRET_KEY",
"E2E_STORAGE_R2_BUCKET_NAME",
"E2E_STORAGE_R2_PORT",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
] as const;
function assertRequiredEnvVars() {
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
const value = process.env[name]?.trim();
return !value;
});
if (missingVars.length > 0) {
throw new Error(
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
);
}
}
assertRequiredEnvVars();
export function getEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
-94
View File
@@ -1,94 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a notification channel card in the list.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Open the edit dialog for an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing notification channel.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Fill the notification channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto` use the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function create(
page: Page,
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add notification channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add notification channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Submit the notification channel creation form.
*
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a notification channel and trigger the test action.
*
* Executes from: `/dashboard/notifications/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await page.getByRole("button", {name: /Test Channel/i}).click();
}
/**
* Close the current notification channel dialog without saving.
*
* Executes from: the add or edit notification channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-59
View File
@@ -1,59 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a project card in the list.
*
* Executes from: `/dashboard/projects`.
*/
export function get(page: Page, projectName: string) {
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
}
/**
* Create a project from the selected entrypoint.
*
* Available entrypoints:
* - `emptyState`: the empty-state CTA.
* - `button`: the classic create button.
*
* * Executes from: `/dashboard/projects`.
*/
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
if (entrypoint === "emptyState") {
await page.getByText("Create new Project", {exact: true}).click();
} else {
await page.getByRole("button", {name: /Create Project/i}).click();
}
await page.getByLabel("Name").fill(projectName);
await page.getByRole("button", {name: "Create"}).click();
}
/**
* Edit an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
*/
export async function edit(page: Page, currentName: string, updatedName: string) {
await get(page, currentName).click();
await page
.getByRole("button", {name: /Delete Project/i})
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
.click();
await page.getByLabel("Name").fill(updatedName);
await page.getByRole("button", {name: "Update"}).click();
}
/**
* Delete an existing project from its details page.
*
* Executes from: `/dashboard/projects/[projectId]`.
* */
export async function remove(page: Page, projectName: string) {
await get(page, projectName).click();
await page.getByRole("button", {name: /Delete Project/i}).click();
await page.getByRole("button", {name: "Delete", exact: true}).click();
}
-1
View File
@@ -1 +0,0 @@
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
-103
View File
@@ -1,103 +0,0 @@
import {Page} from "@playwright/test";
/**
* Locate a storage channel card in the channels list.
*
* Executes from: `/dashboard/storages/channels`.
*/
export function get(page: Page, channelName: string) {
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
has: page.locator('h3', {hasText: channelName}),
}).first();
}
/**
* Fill the storage channel creation form.
*
* Available entrypoints:
* - `button`: the classic add button.
* - `emptyState`: the empty-state CTA.
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function create(
page: Page,
provider: "S3" | "Google Drive",
channelName: string,
fillConfig: (page: Page) => Promise<void>,
entrypoint: "auto" | "emptyState" | "button" = "auto",
) {
if (entrypoint === "auto") {
const addButton = page.getByRole("button", {name: /Add storage channel/i});
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
else await page.getByText("No storage channels configured yet", {exact: true}).click();
} else if (entrypoint === "button") {
await page.getByRole("button", {name: /Add storage channel/i}).click();
} else if (entrypoint === "emptyState") {
await page.getByText("No storage channels configured yet", {exact: true}).click();
}
await page.getByText(provider, {exact: true}).click();
await page.getByLabel(/Channel Name/).fill(channelName);
await fillConfig(page);
}
/**
* Open the edit dialog for an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function edit(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(1).click();
}
/**
* Delete an existing storage channel.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function remove(page: Page, channelName: string) {
const card = get(page, channelName);
await card.locator("button").nth(2).click();
await page.getByRole("button", {name: "Delete"}).click();
}
/**
* Trigger the storage connection test in the current dialog.
*
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function testConnection(page: Page) {
await page.getByRole("button", {name: /Test Storage/i}).click();
}
/**
* Submit the storage channel creation form.
*
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
*/
export async function submit(page: Page) {
await page.getByRole("button", {name: "Add Channel"}).click();
}
/**
* Open the edit dialog for a storage channel and trigger the test action.
*
* Executes from: `/dashboard/storages/channels`.
*/
export async function testFromEdit(page: Page, channelName: string) {
await edit(page, channelName);
await testConnection(page);
}
/**
* Close the current storage channel dialog without saving.
*
* Executes from: the add or edit storage channel dialog.
*/
export async function cancel(page: Page) {
await page.getByRole("button", {name: "Cancel"}).click();
}
-71
View File
@@ -1,71 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Discord E2E Required";
const invalidChannelName = "Discord E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Discord", validChannelName, async (page) => {
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
//
// test("Edit and test a valid Discord channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
// await expect(page.getByText("Sent to Discord")).toBeVisible();
// await cancel(page);
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Discord", invalidChannelName, async (page) => {
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
});
test("Delete invalid Discord channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-66
View File
@@ -1,66 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Gotify E2E Required";
const invalidChannelName = "Gotify E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Gotify", validChannelName, async (page) => {
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
// });
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
//
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Gotify channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Gotify", invalidChannelName, async (page) => {
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
});
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Gotify channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Ntfy E2E Required";
const optionalChannelName = "Ntfy E2E Optional";
const invalidChannelName = "Ntfy E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Ntfy channel with only required fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", requiredChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Ntfy channel with optional fields", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "ntfy.sh", optionalChannelName, async (page) => {
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
// await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
// await page.getByLabel(/^Access Token$/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOKEN"));
// await page.getByLabel(/^Basic Auth Username$/).fill(getEnv("E2E_NOTIFICATION_NTFY_USERNAME"));
// await page.getByLabel(/^Basic Auth Password$/).fill(getEnv("E2E_NOTIFICATION_NTFY_PASSWORD"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "ntfy.sh", invalidChannelName, async (page) => {
await page.getByLabel(/Topic Name/).fill("wrong-ntfy-topic");
await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
await page.getByLabel(/^Access Token$/).fill("wrong-ntfy-token");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Ntfy channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-61
View File
@@ -1,61 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "Slack E2E Required";
const invalidChannelName = "Slack E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Slack channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Slack", validChannelName, async (page) => {
// await page.getByLabel(/Slack Webhook URL/).fill(getEnv("E2E_NOTIFICATION_SLACK_WEBHOOK"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid Slack E2E channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText("Sent to Slack")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Slack", invalidChannelName, async (page) => {
await page.getByLabel(/Slack Webhook URL/).fill("https://WRONG_SLACK_WEBHOOK");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Slack channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const validChannelName = "SMTP E2E Required";
const invalidChannelName = "SMTP E2E Invalid";
const successMessage = `Email sent: ${getEnv("E2E_NOTIFICATION_SMTP_TO")}`;
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Email", validChannelName, async (page) => {
// await page.getByLabel(/SMTP Host/).fill(getEnv("E2E_NOTIFICATION_SMTP_HOST"));
// await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
// await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
// await page.getByLabel(/Password/).fill(getEnv("E2E_NOTIFICATION_SMTP_PASSWORD"));
// await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
// await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
//
// test("Edit and test a valid SMTP channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await expect(get(page, validChannelName)).toBeVisible();
// await testFromEdit(page, validChannelName);
// await expect(page.getByText(successMessage)).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid SMTP E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Email", invalidChannelName, async (page) => {
await page.getByLabel(/SMTP Host/).fill("smtp.invalid");
await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
await page.getByLabel(/Password/).fill("wrong-password");
await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid SMTP channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
View File
-72
View File
@@ -1,72 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Telegram E2E Required";
const optionalChannelName = "Telegram E2E Optional";
const invalidChannelName = "Telegram E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Telegram channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", requiredChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Telegram channel with Topic ID", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Telegram", optionalChannelName, async (page) => {
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
// await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Telegram", invalidChannelName, async (page) => {
await page.getByLabel(/Telegram Bot Token/).fill("wrong-telegram-bot-token");
await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Telegram channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
import {expect, test} from "@playwright/test";
import {
cancel, create, get, remove, submit, testFromEdit,
} from "../helpers/notification";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Webhook E2E Required";
const optionalChannelName = "Webhook E2E Optional";
const invalidChannelName = "Webhook E2E Invalid";
// test.describe.serial("Valid channels", () => {
// test("Create and test a valid Webhook channel", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", requiredChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
// await cancel(page);
// });
//
// test("Create and test a valid Webhook channel with optional header", async ({page}) => {
// await page.goto("/dashboard/notifications/channels");
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", optionalChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
// await page.getByRole("button", { name: "Add Header" }).click();
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
// await page.getByLabel(/^Header Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
// });
// await submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
// await expect(get(page, optionalChannelName)).toBeVisible();
// await testFromEdit(page, optionalChannelName);
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Webhook channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Webhook", invalidChannelName, async (page) => {
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
await page.getByRole("button", { name: "Add Header" }).click();
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
});
await submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await testFromEdit(page, invalidChannelName);
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
await cancel(page);
});
test("Delete invalid Webhook E2E channel", async ({page}) => {
await page.goto("/dashboard/notifications/channels");
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-59
View File
@@ -1,59 +0,0 @@
import {expect, test} from "@playwright/test";
import {create, edit, get, remove} from "./helpers/project";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.use({storageState: LOCAL_STORAGE_PATH});
const project = {
emptyStateName: "Project A",
buttonName: "Project B",
updatedButtonName: "Project B Updated",
};
test.describe.serial(() => {
test("Create a project from empty state", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toBeVisible();
await create(page, "emptyState", project.emptyStateName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await expect(page.getByText("Create new Project", {exact: true})).toHaveCount(0);
});
test("Create a project from classic button", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.emptyStateName)).toBeVisible();
await create(page, "button", project.buttonName);
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
});
test("Edit the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.buttonName)).toBeVisible();
await edit(page, project.buttonName, project.updatedButtonName);
await expect(page.getByText("Project has been successfully updated.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName, {exact: true})).toBeVisible();
});
test("Delete the second created project", async ({page}) => {
await page.goto("/dashboard/projects");
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
await expect(get(page, project.updatedButtonName)).toBeVisible();
await remove(page, project.updatedButtonName);
await expect(page).toHaveURL("/dashboard/projects");
await expect(page.getByText("Projects has been successfully archived.")).toBeVisible();
await expect(page.getByText(project.updatedButtonName)).toHaveCount(0);
});
});
-13
View File
@@ -1,13 +0,0 @@
import {test} from "@playwright/test";
import fs from "fs";
import {LOCAL_STORAGE_PATH} from "./helpers/session";
test.describe(() => {
test.beforeAll(async () => {
if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH);
fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({}));
});
test("Prepare shared storage state", async () => {
});
});
View File
View File
-60
View File
@@ -1,60 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
import {
cancel, create, get, remove, submit, testConnection, testFromEdit,
} from "../helpers/storage";
test.use({storageState: LOCAL_STORAGE_PATH});
const requiredChannelName = "Google Drive E2E Required";
const invalidChannelName = "Google Drive E2E Invalid";
// test.describe.serial("Valid channel", () => {
// test("Create and test a valid Google Drive channel", async ({page}) => {
// await page.goto("/dashboard/storages/channels");
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
// await create(page, "Google Drive", requiredChannelName, async (page) => {
// await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
// await page.getByLabel(/Client Secret/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET"));
// await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
// });
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
// await testConnection(page);
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await submit(page);
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
// await expect(get(page, requiredChannelName)).toBeVisible();
//
// await testFromEdit(page, requiredChannelName);
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test("Create and test invalid Google Drive channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await create(page, "Google Drive", invalidChannelName, async (page) => {
await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
await page.getByLabel(/Client Secret/).fill("wrong-google-drive-client-secret");
await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
});
await testConnection(page);
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
await submit(page);
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
});
test("Delete invalid Google Drive E2E channel", async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await expect(get(page, invalidChannelName)).toBeVisible();
await remove(page, invalidChannelName);
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
});
});
-121
View File
@@ -1,121 +0,0 @@
import {expect, test} from "@playwright/test";
import {getEnv} from "../helpers/env";
import {LOCAL_STORAGE_PATH} from "../helpers/session";
import {
cancel, create, get, remove, submit, testConnection, testFromEdit,
} from "../helpers/storage";
test.use({storageState: LOCAL_STORAGE_PATH});
const providers = [
{
title: "AWS S3",
requiredChannelName: "AWS S3 E2E Required",
optionalChannelName: "AWS S3 E2E Optional",
invalidChannelName: "AWS S3 E2E Invalid",
endpointUrl: getEnv("E2E_STORAGE_AWS_S3_ENDPOINT_URL"),
region: getEnv("E2E_STORAGE_AWS_S3_REGION"),
accessKey: getEnv("E2E_STORAGE_AWS_S3_ACCESS_KEY"),
secretKey: getEnv("E2E_STORAGE_AWS_S3_SECRET_KEY"),
bucketName: getEnv("E2E_STORAGE_AWS_S3_BUCKET_NAME"),
port: getEnv("E2E_STORAGE_AWS_S3_PORT"),
invalidAccessKey: "wrong-aws-access-key",
invalidSecretKey: "wrong-aws-secret-key",
},
{
title: "Cloudflare R2",
requiredChannelName: "Cloudflare R2 E2E Required",
optionalChannelName: "Cloudflare R2 E2E Optional",
invalidChannelName: "Cloudflare R2 E2E Invalid",
endpointUrl: getEnv("E2E_STORAGE_R2_ENDPOINT_URL"),
region: getEnv("E2E_STORAGE_R2_REGION"),
accessKey: getEnv("E2E_STORAGE_R2_ACCESS_KEY"),
secretKey: getEnv("E2E_STORAGE_R2_SECRET_KEY"),
bucketName: getEnv("E2E_STORAGE_R2_BUCKET_NAME"),
port: getEnv("E2E_STORAGE_R2_PORT"),
invalidAccessKey: "wrong-r2-access-key",
invalidSecretKey: "wrong-r2-secret-key",
},
] as const;
for (const provider of providers) {
test.describe(provider.title, () => {
// test.describe.serial("Valid channels", () => {
// test(`Create and test a valid ${provider.title} channel`, async ({page}) => {
// await page.goto("/dashboard/storages/channels");
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
// await create(page, "S3", provider.requiredChannelName, async (page) => {
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
// });
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
// await testConnection(page);
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await submit(page);
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
// await expect(get(page, provider.requiredChannelName)).toBeVisible();
//
// await testFromEdit(page, provider.requiredChannelName);
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await cancel(page);
// });
//
// test(`Create and test a valid ${provider.title} channel with optional region and port`, async ({page}) => {
// await page.goto("/dashboard/storages/channels");
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
// await create(page, "S3", provider.optionalChannelName, async (page) => {
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
// await page.getByLabel(/^Region$/).fill(provider.region);
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
// await page.getByLabel(/^Port$/).fill(provider.port);
// });
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
// await testConnection(page);
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await submit(page);
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
// await expect(get(page, provider.optionalChannelName)).toBeVisible();
//
// await testFromEdit(page, provider.optionalChannelName);
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
// await cancel(page);
// });
// });
test.describe.serial("Invalid channel", () => {
test(`Create and test invalid ${provider.title} channel`, async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await create(page, "S3", provider.invalidChannelName, async (page) => {
await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
await page.getByLabel(/^Region$/).fill(provider.region);
await page.getByLabel(/Access Key/).fill(provider.invalidAccessKey);
await page.getByLabel(/Secret Key/).fill(provider.invalidSecretKey);
await page.getByLabel(/Bucket name/).fill(provider.bucketName);
await page.getByLabel(/^Port$/).fill(provider.port);
});
await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
await testConnection(page);
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
await submit(page);
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
await expect(get(page, provider.invalidChannelName)).toBeVisible();
});
test(`Delete invalid ${provider.title} channel`, async ({page}) => {
await page.goto("/dashboard/storages/channels");
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
await expect(get(page, provider.invalidChannelName)).toBeVisible();
await remove(page, provider.invalidChannelName);
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
await expect(page.getByText(provider.invalidChannelName)).toHaveCount(0);
});
});
});
}
+3 -1
View File
@@ -7,6 +7,7 @@
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"test:api": "jest --config tests/api/jest.config.ts --runInBand",
"email": "email dev --dir ./src/components/emails",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -109,12 +110,12 @@
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.8",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.3.0",
"@types/jest": "^30.0.0",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.19",
"@types/node-forge": "^1.3.14",
@@ -130,6 +131,7 @@
"eslint-config-next": "^16.2.6",
"eslint-plugin-tailwindcss": "^3.18.3",
"framer-motion": "^12.40.0",
"jest": "^30.0.5",
"node-pty": "^1.1.0",
"postcss": "8.5.10",
"release-it": "^19.2.4",
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
...devices['Desktop Chrome'],
baseURL: process.env.PROJECT_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects and dependency order */
projects: [
{
name: 'setup',
testMatch: '**/setup.spec.ts',
},
{
name: 'auth',
testMatch: '**/auth.spec.ts',
dependencies: ['setup'],
},
{
name: 'access-management',
testMatch: '**/access-management.spec.ts',
dependencies: ['auth'],
},
{
name: 'notification',
testMatch: '**/notification/**/*.spec.ts',
dependencies: ['access-management'],
},
{
name: 'storage',
testMatch: '**/storage/**/*.spec.ts',
dependencies: ['access-management'],
},
{
name: 'agent',
testMatch: '**/agent.spec.ts',
dependencies: ['access-management'],
},
{
name: 'project',
testMatch: '**/project.spec.ts',
dependencies: ['access-management'],
},
{
name: 'cleanup',
testMatch: '**/cleanup.spec.ts',
dependencies: ['agent', 'storage', 'notification', 'project'],
},
]
});
+1596 -11
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+3 -1
View File
@@ -18,6 +18,7 @@ import * as backupStorage from "@/db/schema/14_storage-backup";
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
import * as apiKey from "@/db/schema/16_apikey";
import * as jobLog from "@/db/schema/17_job-log";
import * as auditEvent from "@/db/schema/18_audit-event";
const log = logger.child({module: "db"});
@@ -55,7 +56,8 @@ export const schemas = {
...backupStorage,
...healthcheckLog,
...apiKey,
...jobLog
...jobLog,
...auditEvent,
};
export const db = drizzle({
+28
View File
@@ -0,0 +1,28 @@
CREATE TYPE "public"."audit_actor_type" AS ENUM('user', 'api_key', 'agent', 'system');--> statement-breakpoint
CREATE TYPE "public"."audit_outcome" AS ENUM('success', 'failure', 'denied');--> statement-breakpoint
CREATE TABLE "audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"event_type" varchar(128) NOT NULL,
"category" varchar(64) NOT NULL,
"outcome" "audit_outcome" NOT NULL,
"actor_type" "audit_actor_type" NOT NULL,
"actor_id" uuid,
"actor_name" text,
"actor_api_key_id" uuid,
"actor_api_key_name" text,
"organization_id" uuid,
"organization_name" text,
"target_type" varchar(64),
"target_id" uuid,
"target_name" text,
"ip_address" "inet",
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE INDEX "audit_events_created_at_idx" ON "audit_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "audit_events_organization_created_at_idx" ON "audit_events" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "audit_events_category_created_at_idx" ON "audit_events" USING btree ("category","created_at");--> statement-breakpoint
CREATE INDEX "audit_events_event_type_created_at_idx" ON "audit_events" USING btree ("event_type","created_at");--> statement-breakpoint
CREATE INDEX "audit_events_outcome_created_at_idx" ON "audit_events" USING btree ("outcome","created_at");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -435,6 +435,13 @@
"when": 1780822414802,
"tag": "0061_illegal_mole_man",
"breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1781507606925,
"tag": "0062_old_quasimodo",
"breakpoints": true
}
]
}
+64
View File
@@ -0,0 +1,64 @@
import { sql } from "drizzle-orm";
import {
index,
inet,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createSelectSchema } from "drizzle-zod";
import { z } from "zod";
export const auditActorTypeEnum = pgEnum("audit_actor_type", [
"user",
"api_key",
"agent",
"system",
]);
export const auditOutcomeEnum = pgEnum("audit_outcome", [
"success",
"failure",
"denied",
]);
export const auditEvent = pgTable(
"audit_events",
{
id: uuid("id").defaultRandom().primaryKey(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
eventType: varchar("event_type", { length: 128 }).notNull(),
category: varchar("category", { length: 64 }).notNull(),
outcome: auditOutcomeEnum("outcome").notNull(),
actorType: auditActorTypeEnum("actor_type").notNull(),
actorId: uuid("actor_id"),
actorName: text("actor_name"),
actorApiKeyId: uuid("actor_api_key_id"),
actorApiKeyName: text("actor_api_key_name"),
organizationId: uuid("organization_id"),
organizationName: text("organization_name"),
targetType: varchar("target_type", { length: 64 }),
targetId: uuid("target_id"),
targetName: text("target_name"),
ipAddress: inet("ip_address"),
userAgent: text("user_agent"),
metadata: jsonb("metadata")
.$type<Record<string, unknown>>()
.default(sql`'{}'::jsonb`)
.notNull(),
},
(table) => [
index("audit_events_created_at_idx").on(table.createdAt),
index("audit_events_organization_created_at_idx").on(table.organizationId, table.createdAt),
index("audit_events_category_created_at_idx").on(table.category, table.createdAt),
index("audit_events_event_type_created_at_idx").on(table.eventType, table.createdAt),
index("audit_events_outcome_created_at_idx").on(table.outcome, table.createdAt),
],
);
export const auditEventSchema = createSelectSchema(auditEvent);
export type AuditEvent = z.infer<typeof auditEventSchema>;
+3 -1
View File
@@ -52,6 +52,8 @@ export const env = createEnv({
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
),
AUDIT_LOG_RETENTION_DAYS: z.coerce.number().int().default(180),
CLEANING_HEALTHCHECK_LOGS_CRON: z
.string()
.default(
@@ -144,6 +146,7 @@ export const env = createEnv({
SMTP_SECURE: process.env.SMTP_SECURE,
RETENTION_CRON: process.env.RETENTION_CRON,
AUDIT_LOG_RETENTION_DAYS: process.env.AUDIT_LOG_RETENTION_DAYS,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
@@ -200,4 +203,3 @@ export const env = createEnv({
},
});
+56
View File
@@ -0,0 +1,56 @@
import {
auditEventEnrichmentSchema, type AuditEventEnrichment, type WithAuditEventConfigBase, withAuditEventConfigSchema,
} from "@/lib/audit/schema";
import {createAuditEvent} from "@/lib/audit/create-audit-event";
type WithAuditEventCallback<TResult> = (value: TResult | unknown) => AuditEventEnrichment | void;
type WithAuditEventResult = { success: boolean };
export type WithAuditEventConfig<TResult extends WithAuditEventResult> = WithAuditEventConfigBase & {
onSuccess?: (result: TResult) => AuditEventEnrichment | void;
onFailure?: WithAuditEventCallback<TResult>;
};
export async function withAuditEvent<TResult extends WithAuditEventResult>(
effect: () => Promise<TResult>,
config: WithAuditEventConfig<TResult>,
) {
const {onSuccess, onFailure, ...rawConfig} = config;
const parsedConfig = withAuditEventConfigSchema.parse(rawConfig);
const writeOutcome = async (
outcome: "success" | "failure" | "denied",
enrichmentValue: AuditEventEnrichment | void,
) => {
const enrichment = enrichmentValue ? auditEventEnrichmentSchema.parse(enrichmentValue) : {};
await createAuditEvent({
eventType: parsedConfig.eventType,
outcome,
actor: parsedConfig.actor,
organization: enrichment?.organization === undefined ? parsedConfig.organization ?? null : enrichment.organization ?? null,
target: enrichment?.target === undefined ? parsedConfig.target ?? null : enrichment.target ?? null,
ipAddress: parsedConfig.ipAddress ?? null,
userAgent: parsedConfig.userAgent ?? null,
metadata: {...parsedConfig.metadata, ...enrichment.metadata},
});
};
try {
const result = await effect();
if (!result.success) {
await writeOutcome("failure", onFailure?.(result));
return result;
}
await writeOutcome("success", onSuccess?.(result));
return result;
} catch (error) {
await writeOutcome("failure", onFailure?.(error));
throw error;
}
}
+30
View File
@@ -0,0 +1,30 @@
import {writeContext} from "./helpers/context";
import {ApiHttpClient} from "./helpers/http-client";
import {signInDefaultUser, createStandardApiKey} from "./helpers/auth";
import {discoverAgentId, discoverBackupContext, discoverDatabaseId,} from "./helpers/discovery";
import {env} from "@/env.mjs";
export default async function globalSetup() {
const baseUrl = env.PROJECT_URL;
const client = new ApiHttpClient(baseUrl);
const auth = await signInDefaultUser(client);
const apiKey = await createStandardApiKey(client);
const api = client.withApiKey(apiKey);
const agentId = await discoverAgentId(api);
const databaseId = await discoverDatabaseId(api);
const backup = await discoverBackupContext(api, databaseId);
writeContext({
baseUrl,
authCookie: client.getCookieHeader(),
apiKey,
userEmail: auth.email,
agentId,
databaseId,
backupId: backup.backupId,
backupStorageId: backup.backupStorageId,
});
}
+50
View File
@@ -0,0 +1,50 @@
import {ApiHttpClient} from "./http-client";
import {env} from "@/env.mjs";
export async function signInDefaultUser(client: ApiHttpClient) {
const email = env.AUTH_DEFAULT_USER;
const password = env.AUTH_DEFAULT_PASSWORD;
if (!email || !password) throw new Error("AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD are required");
const result = await client.request("POST", "/api/auth/sign-in/email", {
email,
password,
callbackURL: "/dashboard",
});
if (!result.response.ok) {
throw new Error(`Sign-in failed: ${result.response.status} ${result.text}`);
}
const cookie = client.getCookieHeader();
if (!cookie) {
throw new Error("Sign-in succeeded but no auth cookie was captured");
}
return {
email,
cookie,
};
}
export async function createStandardApiKey(client: ApiHttpClient) {
const result = await client.request("POST", "/api/auth/api-key/create", {
name: "api-test-key",
configId: "standard",
});
if (!result.response.ok) {
throw new Error(
`API key creation failed: ${result.response.status} ${result.text}`,
);
}
const payload = result.json as { key?: string };
if (!payload?.key) {
throw new Error(`API key payload missing key: ${result.text}`);
}
return payload.key;
}
+29
View File
@@ -0,0 +1,29 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export type ApiTestContext = {
baseUrl: string;
authCookie: string;
apiKey: string;
userEmail: string;
agentId: string | null;
databaseId: string | null;
backupId: string | null;
backupStorageId: string | null;
};
export const CONTEXT_PATH = fileURLToPath(
new URL("../.runtime-context.json", import.meta.url),
);
export function writeContext(context: ApiTestContext) {
writeFileSync(CONTEXT_PATH, JSON.stringify(context, null, 2), "utf8");
}
export function readContext(): ApiTestContext {
if (!existsSync(CONTEXT_PATH)) {
throw new Error(`Missing API test context at ${CONTEXT_PATH}`);
}
return JSON.parse(readFileSync(CONTEXT_PATH, "utf8")) as ApiTestContext;
}
+135
View File
@@ -0,0 +1,135 @@
function readIdArray(json: unknown, resourceName: string): Array<{ id: string }> {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error(`Invalid ${resourceName} response: missing data array`);
}
const data = (json as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error(`Invalid ${resourceName} response: data is not an array`);
}
for (const item of data) {
if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") {
throw new Error(
`Invalid ${resourceName} response: each item must include a string id`,
);
}
}
return data as Array<{ id: string }>;
}
function readBackupDetail(json: unknown) {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error("Invalid backup detail response: missing data object");
}
const data = (json as { data?: unknown }).data;
if (!data || typeof data !== "object") {
throw new Error("Invalid backup detail response: data is not an object");
}
const id = (data as { id?: unknown }).id;
const storages = (data as { storages?: unknown }).storages;
if (typeof id !== "string") {
throw new Error("Invalid backup detail response: data.id must be a string");
}
if (!Array.isArray(storages)) {
throw new Error(
"Invalid backup detail response: data.storages is not an array",
);
}
for (const item of storages) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { id?: unknown }).id !== "string" ||
typeof (item as { status?: unknown }).status !== "string"
) {
throw new Error(
"Invalid backup detail response: each storage must include string id and status",
);
}
}
return {
id,
storages: storages as Array<{ id: string; status: string }>,
};
}
export async function discoverAgentId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/agents");
if (!result.response.ok) {
throw new Error(`Failed to list agents: ${result.response.status} ${result.text}`);
}
const agents = readIdArray(result.json, "agents");
if (agents.length === 0) {
throw new Error("No agent could be discovered from /api/v1/agents.");
}
return agents[0].id;
}
export async function discoverDatabaseId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/databases");
if (!result.response.ok) {
throw new Error(
`Failed to list databases: ${result.response.status} ${result.text}`,
);
}
const databases = readIdArray(result.json, "databases");
if (databases.length === 0) {
throw new Error("No database could be discovered from /api/v1/databases.");
}
return databases[0].id;
}
export async function discoverBackupContext(
api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
},
databaseId: string | null,
) {
if (!databaseId) {
throw new Error("Backup discovery requires a database id.");
}
const backups = await api.get(`/api/v1/databases/${databaseId}/backup`);
if (!backups.response.ok) {
throw new Error(
`Failed to list backups: ${backups.response.status} ${backups.text}`,
);
}
const backupList = readIdArray(backups.json, "backups");
for (const backup of backupList) {
const detail = await api.get(`/api/v1/databases/${databaseId}/backup/${backup.id}`);
if (!detail.response.ok) {
continue;
}
const payload = readBackupDetail(detail.json);
const storage = payload.storages.find((item) => item.status === "success");
if (storage) {
return {
backupId: payload.id,
backupStorageId: storage.id,
};
}
}
throw new Error(
`No successful backup storage could be discovered for database ${databaseId}.`,
);
}
+98
View File
@@ -0,0 +1,98 @@
type JsonValue =
| Record<string, unknown>
| Array<unknown>
| string
| number
| boolean
| null;
function mergeCookieHeaders(
currentCookieHeader: string,
setCookies: string[],
): string {
const cookies = new Map<string, string>();
for (const value of currentCookieHeader.split("; ")) {
if (!value) {
continue;
}
const [name, ...rest] = value.split("=");
cookies.set(name, rest.join("="));
}
for (const value of setCookies) {
const [cookiePair] = value.split(";");
const [name, ...rest] = cookiePair.split("=");
cookies.set(name, rest.join("="));
}
return Array.from(cookies.entries())
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
export class ApiHttpClient {
constructor(
private readonly baseUrl: string,
private cookieHeader = "",
) {}
getCookieHeader() {
return this.cookieHeader;
}
withApiKey(apiKey: string) {
return {
get: (path: string) =>
this.request("GET", path, undefined, { "x-api-key": apiKey }),
post: (path: string, body?: JsonValue) =>
this.request("POST", path, body, { "x-api-key": apiKey }),
patch: (path: string, body?: JsonValue) =>
this.request("PATCH", path, body, { "x-api-key": apiKey }),
delete: (path: string) =>
this.request("DELETE", path, undefined, { "x-api-key": apiKey }),
};
}
async request(
method: string,
path: string,
body?: JsonValue,
headers: HeadersInit = {},
) {
const requestHeaders = new Headers({
origin: new URL(this.baseUrl).origin,
...(body ? { "content-type": "application/json" } : {}),
...(this.cookieHeader ? { cookie: this.cookieHeader } : {}),
});
new Headers(headers).forEach((value, name) => {
requestHeaders.set(name, value);
});
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: requestHeaders,
...(body ? { body: JSON.stringify(body) } : {}),
});
const setCookies = response.headers.getSetCookie?.() ?? [];
if (setCookies.length > 0) {
this.cookieHeader = mergeCookieHeaders(this.cookieHeader, setCookies);
}
const text = await response.text();
let json: unknown = null;
if (text) {
try {
json = JSON.parse(text);
} catch {
json = text;
}
}
return { response, json, text };
}
}
+22
View File
@@ -0,0 +1,22 @@
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
dir: "./",
});
const config = {
displayName: "api",
testEnvironment: "node",
rootDir: "../../",
roots: ["<rootDir>/tests/api"],
testMatch: ["<rootDir>/tests/api/v1/**/*.test.ts"],
globalSetup: "<rootDir>/tests/api/global-setup.ts",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testTimeout: 60_000,
maxWorkers: 1,
verbose: true,
};
export default createJestConfig(config);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["./**/*.ts"]
}
+53
View File
@@ -0,0 +1,53 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
function requireApiKey() {
const context = readContext();
if (!context.apiKey) {
throw new Error(
"Authenticated API tests require AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
return context;
}
describe("api v1 agents", () => {
const context = requireApiKey();
const client = new ApiHttpClient(context.baseUrl);
const api = client.withApiKey(context.apiKey);
it("lists accessible agents", async () => {
const { response, json } = await api.get("/api/v1/agents");
expect(response.status).toBe(200);
const payload = json as { data: Array<{ id: string }> };
expect(Array.isArray(payload.data)).toBe(true);
expect(payload.data.some((agent) => agent.id === context.agentId)).toBe(
true,
);
});
it("returns one agent by id", async () => {
const { response, json } = await api.get(`/api/v1/agents/${context.agentId}`);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.agentId);
});
it("returns an edge key for the discovered agent", async () => {
const { response, json } = await api.get(
`/api/v1/agents/${context.agentId}/key`,
);
expect(response.status).toBe(200);
expect(typeof (json as { data: string }).data).toBe("string");
});
it("returns 404 for an unknown agent id", async () => {
const { response, json } = await api.get(
"/api/v1/agents/11111111-1111-1111-1111-111111111111",
);
expect(response.status).toBe(404);
expect(json).toEqual({ error: "Not found" });
});
});
+25
View File
@@ -0,0 +1,25 @@
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 auth middleware", () => {
const client = new ApiHttpClient(baseUrl);
it("rejects missing api keys", async () => {
const { response, json } = await client.request("GET", "/api/v1/agents");
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Missing API key" });
});
it("rejects invalid api keys", async () => {
const { response, json } = await client.request("GET", "/api/v1/agents", undefined, {
"x-api-key": "sk_invalid",
});
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Invalid or expired API key" });
});
});
+109
View File
@@ -0,0 +1,109 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
function requireDatabaseContext() {
const context = readContext();
if (!context.apiKey) {
throw new Error(
"Authenticated database API tests require AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
if (!context.databaseId) {
throw new Error(
"Database API tests require a discovered database or API_TEST_DATABASE_ID.",
);
}
return context;
}
describe("api v1 databases", () => {
const context = requireDatabaseContext();
const client = new ApiHttpClient(context.baseUrl);
const api = client.withApiKey(context.apiKey);
it("lists accessible databases", async () => {
const { response, json } = await api.get("/api/v1/databases");
expect(response.status).toBe(200);
const payload = json as { data: Array<{ id: string }> };
expect(Array.isArray(payload.data)).toBe(true);
expect(
payload.data.some((database) => database.id === context.databaseId),
).toBe(true);
});
it("returns one database by id", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}`,
);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.databaseId);
});
it("returns database status", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/status`,
);
expect(response.status).toBe(200);
const data = (json as {
data: {
isWaitingForBackup: boolean;
latestBackup: unknown;
latestRestoration: unknown;
};
}).data;
expect(typeof data.isWaitingForBackup).toBe("boolean");
expect(data).toHaveProperty("latestBackup");
expect(data).toHaveProperty("latestRestoration");
});
it("lists backups for the discovered database", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/backup`,
);
expect(response.status).toBe(200);
expect(Array.isArray((json as { data: unknown[] }).data)).toBe(true);
});
it("returns one backup with storages when backup context is available", async () => {
if (!context.backupId) {
throw new Error(
"Backup detail coverage requires a discovered backup or API_TEST_BACKUP_ID.",
);
}
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/backup/${context.backupId}`,
);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.backupId);
});
it("can trigger restore when backup and storage ids are available", async () => {
if (!context.backupId || !context.backupStorageId) {
throw new Error(
"Restore coverage requires discovered backup and backup storage ids or API_TEST_BACKUP_ID/API_TEST_BACKUP_STORAGE_ID.",
);
}
const { response, json } = await api.post(
`/api/v1/databases/${context.databaseId}/restore`,
{
backupId: context.backupId,
backupStorageId: context.backupStorageId,
},
);
expect([201, 409]).toContain(response.status);
if (response.status === 201) {
expect((json as { data: { status: string } }).data.status).toBe(
"waiting",
);
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 mcp", () => {
const context = readContext();
const client = new ApiHttpClient(baseUrl);
it("rejects missing api key", async () => {
const { response, json } = await client.request("POST", "/api/v1/mcp", {
jsonrpc: "2.0",
id: "1",
method: "tools/list",
params: {},
});
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Missing API key" });
});
it("passes a valid api key through the middleware", async () => {
if (!context.apiKey) {
throw new Error(
"Authenticated MCP coverage requires AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
const api = client.withApiKey(context.apiKey);
const { response } = await api.post("/api/v1/mcp", {
jsonrpc: "2.0",
id: "1",
method: "tools/list",
params: {},
});
expect([200, 202, 400]).toContain(response.status);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 docs and spec", () => {
const client = new ApiHttpClient(baseUrl);
it("serves the OpenAPI document", async () => {
const { response, json } = await client.request("GET", "/api/v1/openapi");
expect(response.status).toBe(200);
expect((json as { openapi: string }).openapi).toMatch(/^3\./);
});
it("serves the docs page", async () => {
const { response, text } = await client.request("GET", "/api/v1/docs");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(text).toContain("<div id=\"swagger-ui\"></div>");
expect(text).toContain("Portabase API Docs");
expect(text).toContain("/api/v1/openapi");
});
});