From ec0d1d55aa374cd9e76723d2514f87d51799a97b Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Mon, 7 Sep 2026 11:53:45 +0200 Subject: [PATCH 01/11] feat: add api spec files. --- src/api/agent.spec.ts | 0 src/api/database.spec.ts | 0 src/api/organisation.spec.ts | 0 src/api/project.spec.ts | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/api/agent.spec.ts create mode 100644 src/api/database.spec.ts create mode 100644 src/api/organisation.spec.ts create mode 100644 src/api/project.spec.ts diff --git a/src/api/agent.spec.ts b/src/api/agent.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/api/organisation.spec.ts b/src/api/organisation.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/api/project.spec.ts b/src/api/project.spec.ts new file mode 100644 index 0000000..e69de29 From 07a5ef5fc58238520760d0c92caae7f7b20c5f17 Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Wed, 9 Sep 2026 10:29:00 +0200 Subject: [PATCH 02/11] test(api): cover REST API contract --- docker/api/docker-compose.yml | 32 +++++++++++++ docker/api/seed.sql | 2 + docker/server/.env | 3 ++ playwright.config.ts | 14 ++++-- src/api/README.md | 33 +++++++++++++ src/api/agent.spec.ts | 27 +++++++++++ src/api/contract.spec.ts | 31 ++++++++++++ src/api/database.spec.ts | 90 +++++++++++++++++++++++++++++++++++ src/api/endpoints.ts | 17 +++++++ src/api/fixtures.ts | 54 +++++++++++++++++++++ src/api/organisation.spec.ts | 33 +++++++++++++ src/api/project.spec.ts | 25 ++++++++++ 12 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 docker/api/docker-compose.yml create mode 100644 docker/api/seed.sql create mode 100644 src/api/README.md create mode 100644 src/api/contract.spec.ts create mode 100644 src/api/endpoints.ts create mode 100644 src/api/fixtures.ts diff --git a/docker/api/docker-compose.yml b/docker/api/docker-compose.yml new file mode 100644 index 0000000..fd4220b --- /dev/null +++ b/docker/api/docker-compose.yml @@ -0,0 +1,32 @@ +name: portabase-api-e2e +services: + api-postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: api_e2e + POSTGRES_USER: backup + POSTGRES_PASSWORD: backup + healthcheck: + test: ["CMD-SHELL", "pg_isready -U backup -d api_e2e"] + interval: 2s + timeout: 2s + retries: 30 + volumes: + - ./seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro + networks: [portabase] + agent: + image: ${AGENT_IMAGE:-portabase/agent:latest} + environment: + EDGE_KEY: ${EDGE_KEY} + POLLING: 2 + TZ: UTC + volumes: + - ${API_AGENT_CONFIG:?API_AGENT_CONFIG is required}:/config/config.json:ro + depends_on: + api-postgres: + condition: service_healthy + extra_hosts: ["localhost:host-gateway"] + networks: [portabase] +networks: + portabase: + external: true diff --git a/docker/api/seed.sql b/docker/api/seed.sql new file mode 100644 index 0000000..e5cf23a --- /dev/null +++ b/docker/api/seed.sql @@ -0,0 +1,2 @@ +CREATE TABLE e2e_restore_probe (id integer PRIMARY KEY, value text NOT NULL); +INSERT INTO e2e_restore_probe VALUES (1, 'original'); diff --git a/docker/server/.env b/docker/server/.env index 21f7dc5..0997380 100644 --- a/docker/server/.env +++ b/docker/server/.env @@ -30,3 +30,6 @@ AUTH_OIDC_AUTHENTIK_ALLOWED_GROUP="admin" AUTH_OIDC_AUTHENTIK_DEFAULT_ROLE="admin" TELEMETRY=false + +API_ENABLED=true +OPENAPI_ENABLED=true diff --git a/playwright.config.ts b/playwright.config.ts index 6d9eaee..d71f679 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; +import {existsSync} from "node:fs"; dotenv.config({ path: path.resolve(__dirname, ".env") }); @@ -59,18 +60,25 @@ export default defineConfig({ }, { name: "agent", - testMatch: "**/agent.spec.ts", + testMatch: /[\\/]src[\\/]agent\.spec\.ts$/, dependencies: ["access-management"], }, { name: "project", - testMatch: "**/project.spec.ts", + testMatch: /[\\/]src[\\/]project\.spec\.ts$/, dependencies: ["agent"], }, + { + name: "api", + testMatch: /[\\/]src[\\/]api[\\/].*\.spec\.ts$/, + dependencies: ["project", ...(existsSync(path.resolve(__dirname, "src/dashboard.spec.ts")) ? ["dashboard"] : [])], + fullyParallel: false, + retries: 0, + }, { name: "cleanup", testMatch: "**/cleanup.spec.ts", - dependencies: ["storage", "notification", "project"], + dependencies: ["storage", "notification", "project", "api"], }, ], }); diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000..0de460f --- /dev/null +++ b/src/api/README.md @@ -0,0 +1,33 @@ +# REST API E2E coverage + +Reference: https://portabase.io/docs/dashboard/api/introduction + +Enable `API_ENABLED=true` and `OPENAPI_ENABLED=true` (included in `docker/server/.env`). +The API project runs after the project tests, and after dashboard when that feature +branch is present, so temporary API resources do not affect the dashboard counters. +The cleanup project waits for the API tests before revoking the shared UI session. + +Run the suite with `pnpm exec playwright test --project=api`. Against an already +initialized E2E environment and saved authenticated session, add `--no-deps`. +API keys are created through Account Settings, kept in memory, and revoked at the +end of each worker. No external account or manual token configuration is required. + +`contract.spec.ts` compares all 25 operations with `/api/v1/openapi`, checks Swagger +UI, and tests missing and invalid `x-api-key` headers on every operation. + +| Test | Methods and paths under `/api/v1` | Behavior | +| --- | --- | --- | +| agent.spec.ts | GET/POST `/agents`, GET/DELETE `/agents/{id}`, GET `/agents/{id}/key` | Create, list, read, edge key, delete; invalid payload/JSON and missing IDs | +| organisation.spec.ts | GET/POST `/organizations`, GET/DELETE `/organizations/{id}` | Create, list, read, duplicate slug, delete, validation and missing IDs | +| organisation.spec.ts | GET/POST `/organizations/{id}/agents`, DELETE `/organizations/{id}/agents/{agentId}` | Attach, list, duplicate attachment, detach and invalid IDs | +| project.spec.ts | GET/POST `/organizations/{id}/projects`, GET/DELETE `/projects/{id}` | Create, list, read, duplicate slug, archive; reject deleting an organization with a live project | +| database.spec.ts | GET `/databases`, GET/PATCH `/databases/{id}`, PUT `/databases/{id}/backup-policy` | Discover agent source, read, attach/detach, save/clear cron and reject invalid input | +| database.spec.ts | GET `/databases/{id}/status`, GET/POST `/databases/{id}/backup`, GET `/databases/{id}/backup/{backupId}`, POST `/databases/{id}/restore` | Backup, poll success and storage records, mutate a SQL value, restore, verify original SQL data; invalid restore and missing backups | + +The database test owns a temporary organization, project, agent and PostgreSQL +container, defined in `docker/api`. It deletes only those resources on completion. +Its generated database ID and configuration are stored in a temporary directory. + +The backup-policy request uses `schedule`, as accepted by the route implementation. +The OpenAPI version originally inspected described this property as `backupPolicy`; +this discrepancy is deliberately documented instead of sending an unsupported body. diff --git a/src/api/agent.spec.ts b/src/api/agent.spec.ts index e69de29..227d15d 100644 --- a/src/api/agent.spec.ts +++ b/src/api/agent.spec.ts @@ -0,0 +1,27 @@ +import {test, expect, data, error, uniqueName} from "./fixtures"; +import {apiPath, missingId} from "./endpoints"; + +test("Agent API creates, lists, reads, retrieves an edge key and deletes an agent", async ({api}) => { + const name = uniqueName("agent"); + const agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); + expect(agent).toMatchObject({id: expect.any(String), name}); + try { + expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id, name})])); + expect(await data(await api.get(apiPath(`/agents/${agent.id}`)))).toMatchObject({id: agent.id, name}); + const key = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); + expect(key.length).toBeGreaterThan(0); + } finally { + const deleted = await api.delete(apiPath(`/agents/${agent.id}`)); + expect(deleted.status()).toBe(204); + expect(await deleted.body()).toHaveLength(0); + } + await error(await api.get(apiPath(`/agents/${agent.id}`)), 404); + expect(await data(await api.get(apiPath("/agents")))).not.toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id})])); +}); + +test("Agent API validates payloads and unknown IDs", async ({api}) => { + await error(await api.post(apiPath("/agents"), {data: {name: ""}}), 422); + await error(await api.post(apiPath("/agents"), {data: "{", headers: {"Content-Type": "application/json"}}), 422); + for (const suffix of ["", "/key"]) await error(await api.get(apiPath(`/agents/${missingId}${suffix}`)), 404); + await error(await api.delete(apiPath(`/agents/${missingId}`)), 404); +}); diff --git a/src/api/contract.spec.ts b/src/api/contract.spec.ts new file mode 100644 index 0000000..bbd8b34 --- /dev/null +++ b/src/api/contract.spec.ts @@ -0,0 +1,31 @@ +import {test, expect} from "@playwright/test"; +import {apiPath, endpoints, missingId} from "./endpoints"; + +test("OpenAPI exposes every mapped REST operation and API-key authentication", async ({request}) => { + const response = await request.get(apiPath("/openapi")); + expect(response.status()).toBe(200); + const spec = await response.json(); + expect(spec.openapi).toMatch(/^3\./); + expect(spec.components.securitySchemes.apiKeyAuth).toMatchObject({type: "apiKey", in: "header", name: "x-api-key"}); + const actual = Object.entries(spec.paths).flatMap(([path, item]) => Object.keys(item as object) + .filter(method => ["get", "post", "put", "patch", "delete"].includes(method)) + .map(method => `${method.toUpperCase()} ${path}`)); + expect(actual.sort()).toEqual(endpoints.map(([method, path]) => `${method} ${path}`).sort()); + const docs = await request.get(apiPath("/docs")); + expect(docs.status()).toBe(200); + expect(await docs.text()).toContain("swagger"); +}); + +for (const [method, template] of endpoints) { + for (const key of [undefined, "invalid-e2e-api-key"]) { + test(`${method} ${template} rejects ${key ? "invalid" : "missing"} API key`, async ({request}) => { + const response = await request.fetch(apiPath(template.replace(/\{\w+\}/g, missingId)), { + method, + headers: key ? {"x-api-key": key} : {}, + ...(["POST", "PUT", "PATCH"].includes(method) ? {data: {}} : {}), + }); + expect(response.status()).toBe(401); + expect(await response.json()).toMatchObject({error: expect.any(String)}); + }); + } +} diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts index e69de29..1a56756 100644 --- a/src/api/database.spec.ts +++ b/src/api/database.spec.ts @@ -0,0 +1,90 @@ +import {test, expect, data, error, uniqueName} from "./fixtures"; +import {apiPath, missingId} from "./endpoints"; +import {execFileSync} from "node:child_process"; +import {mkdtempSync, writeFileSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {randomUUID} from "node:crypto"; + +test("Database API assigns projects, updates schedules, backs up and restores actual data", async ({api}) => { + test.setTimeout(12 * 60_000); + const directory = mkdtempSync(path.join(tmpdir(), "portabase-api-")); + const config = path.join(directory, "databases.json"); + const generatedId = randomUUID(); + writeFileSync(config, JSON.stringify({databases: [{ + name: "API PostgreSQL", type: "postgresql", host: "api-postgres", port: 5432, + database: "api_e2e", username: "backup", password: "backup", generated_id: generatedId, + }]})); + const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("database org")}}), 201); + let agent: any; + let project: any; + let edgeKey = ""; + const compose = (...args: string[]) => execFileSync("docker", ["compose", "-f", "docker/api/docker-compose.yml", ...args], { + cwd: path.resolve(__dirname, "../.."), + env: {...process.env, EDGE_KEY: edgeKey, API_AGENT_CONFIG: config}, + encoding: "utf8", timeout: 240_000, stdio: ["ignore", "pipe", "pipe"], + }); + try { + project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: uniqueName("database project")}}), 201); + agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("database agent"), organizationId: org.id}}), 201); + edgeKey = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); + compose("up", "-d"); + let database: any; + await expect(async () => { + const databases = await data(await api.get(apiPath("/databases"))); + database = databases.find(db => db.agentDatabaseId === generatedId); + expect(database?.lastContact).toBeTruthy(); + }).toPass({timeout: 90_000, intervals: [2_000]}); + const route = apiPath(`/databases/${database.id}`); + await error(await api.get(route), 403); + await error(await api.patch(route, {data: {projectId: "invalid"}}), 422); + expect(await data(await api.patch(route, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); + expect(await data(await api.patch(route, {data: {projectId: null}}))).toMatchObject({projectId: null}); + await data(await api.patch(route, {data: {projectId: project.id}})); + expect(await data(await api.get(route))).toMatchObject({id: database.id, agentDatabaseId: generatedId, name: "API PostgreSQL"}); + await error(await api.put(`${route}/backup-policy`, {data: {schedule: "invalid"}}), 422); + expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); + expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); + expect(await data(await api.get(`${route}/backup`))).toEqual([]); + await error(await api.get(`${route}/backup/${missingId}`), 404); + await error(await api.post(`${route}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); + await error(await api.post(`${route}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); + const backup = await data(await api.post(`${route}/backup`), 201); + expect(backup).toMatchObject({databaseId: database.id, status: "waiting"}); + let completed: any; + await expect(async () => { + completed = await data(await api.get(`${route}/backup/${backup.id}`)); + expect(completed.status).toBe("success"); + expect(completed.storages).toEqual(expect.arrayContaining([expect.objectContaining({status: "success"})])); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + expect(await data(await api.get(`${route}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); + expect(await data(await api.get(`${route}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); + const sql = (query: string) => compose("exec", "-T", "api-postgres", "psql", "-U", "backup", "-d", "api_e2e", "-Atc", query).trim(); + expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("original"); + sql("UPDATE e2e_restore_probe SET value = 'changed' WHERE id = 1"); + expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("changed"); + const storage = completed.storages.find((item: any) => item.status === "success"); + const restore = await data(await api.post(`${route}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); + expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); + await expect(async () => { + expect(await data(await api.get(`${route}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("original"); + } finally { + try { + compose("down", "--volumes"); + } finally { + if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); + if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); + rmSync(directory, {recursive: true, force: true}); + } + } +}); + +test("Database API rejects unknown resources", async ({api}) => { + for (const suffix of ["", "/status", "/backup", `/backup/${missingId}`]) { + await error(await api.get(apiPath(`/databases/${missingId}${suffix}`)), 404); + } + await error(await api.post(apiPath(`/databases/${missingId}/backup`)), 404); +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000..c879ec7 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,17 @@ +export const endpoints = [ + ["GET", "/agents"], ["POST", "/agents"], + ["GET", "/agents/{id}"], ["DELETE", "/agents/{id}"], ["GET", "/agents/{id}/key"], + ["GET", "/databases"], ["GET", "/databases/{id}"], ["PATCH", "/databases/{id}"], + ["GET", "/databases/{id}/status"], ["GET", "/databases/{id}/backup"], ["POST", "/databases/{id}/backup"], + ["GET", "/databases/{id}/backup/{backupId}"], ["POST", "/databases/{id}/restore"], + ["PUT", "/databases/{id}/backup-policy"], + ["GET", "/organizations"], ["POST", "/organizations"], + ["GET", "/organizations/{id}"], ["DELETE", "/organizations/{id}"], + ["GET", "/organizations/{id}/projects"], ["POST", "/organizations/{id}/projects"], + ["GET", "/organizations/{id}/agents"], ["POST", "/organizations/{id}/agents"], + ["DELETE", "/organizations/{id}/agents/{agentId}"], + ["GET", "/projects/{id}"], ["DELETE", "/projects/{id}"], +] as const; + +export const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +export const apiPath = (path: string) => `/api/v1${path}`; diff --git a/src/api/fixtures.ts b/src/api/fixtures.ts new file mode 100644 index 0000000..683ce05 --- /dev/null +++ b/src/api/fixtures.ts @@ -0,0 +1,54 @@ +import {test as base, expect, APIRequestContext, APIResponse} from "@playwright/test"; +import {randomUUID} from "node:crypto"; +import {LOCAL_STORAGE_PATH} from "../helpers/session"; + +export {expect}; +export const uniqueName = (kind: string) => `E2E API ${kind} ${randomUUID()}`; + +export async function data(response: APIResponse, status = 200): Promise { + expect(response.status(), `${response.url()}: ${await response.text()}`).toBe(status); + const body = await response.json(); + expect(body).toHaveProperty("data"); + return body.data as T; +} + +export async function error(response: APIResponse, status: number) { + expect(response.status(), `${response.url()}: ${await response.text()}`).toBe(status); + expect(await response.json()).toMatchObject({error: expect.any(String)}); +} + +export const test = base.extend<{api: APIRequestContext}, {apiKey: string}>({ + apiKey: [async ({browser}, use) => { + const context = await browser.newContext({storageState: LOCAL_STORAGE_PATH, baseURL: process.env.SERVER_URL}); + const page = await context.newPage(); + const name = `e2e-${randomUUID().slice(0, 12)}`; + await page.goto("/dashboard/home"); + await page.getByTestId("profile-dropdown").first().click(); + await page.getByRole("menuitem", {name: "Account Settings", exact: true}).click(); + await page.getByRole("tab", {name: "Account", exact: true}).click(); + await page.getByRole("button", {name: "Add API Key", exact: true}).click(); + await page.getByLabel("Key Name", {exact: true}).fill(name); + await page.getByRole("button", {name: "Create API Key", exact: true}).click(); + const keyDialog = page.getByRole("dialog", {name: "Your API Key", exact: true}); + await expect(keyDialog).toBeVisible(); + const key = await keyDialog.locator("input[readonly]").inputValue(); + expect(key.length).toBeGreaterThan(10); + await page.getByRole("button", {name: "I copied my API Key", exact: true}).click(); + try { + await use(key); + } finally { + const row = page.locator("div.flex.items-center.justify-between.p-4").filter({hasText: name}); + await row.getByRole("button", {name: "Revoke", exact: true}).click(); + await expect(row).toHaveCount(0); + await context.close(); + } + }, {scope: "worker"}], + api: async ({playwright, apiKey}, use) => { + const api = await playwright.request.newContext({ + baseURL: process.env.SERVER_URL, + extraHTTPHeaders: {"x-api-key": apiKey}, + }); + await use(api); + await api.dispose(); + }, +}); diff --git a/src/api/organisation.spec.ts b/src/api/organisation.spec.ts index e69de29..99f4159 100644 --- a/src/api/organisation.spec.ts +++ b/src/api/organisation.spec.ts @@ -0,0 +1,33 @@ +import {test, expect, data, error, uniqueName} from "./fixtures"; +import {apiPath, missingId} from "./endpoints"; + +test("Organization API creates, lists, reads, attaches and detaches an agent, and deletes", async ({api}) => { + const name = uniqueName("organization"); + const org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); + let agent: {id: string} | undefined; + try { + expect(org).toMatchObject({id: expect.any(String), name}); + expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([expect.objectContaining({id: org.id})])); + expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); + await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); + agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("attached agent")}}), 201); + const route = apiPath(`/organizations/${org.id}/agents`); + expect(await data(await api.post(route, {data: {agentId: agent!.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent!.id}); + await error(await api.post(route, {data: {agentId: agent!.id}}), 422); + expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent!.id})])); + expect(await data(await api.delete(`${route}/${agent!.id}`))).toEqual({organizationId: org.id, agentId: agent!.id}); + expect(await data(await api.get(route))).toEqual([]); + await error(await api.post(route, {data: {agentId: "invalid"}}), 422); + } finally { + if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); + } + await error(await api.get(apiPath(`/organizations/${org.id}`)), 404); +}); + +test("Organization API rejects invalid input and unknown resources", async ({api}) => { + await error(await api.post(apiPath("/organizations"), {data: {name: ""}}), 422); + await error(await api.get(apiPath(`/organizations/${missingId}`)), 404); + await error(await api.delete(apiPath(`/organizations/${missingId}`)), 404); + for (const child of ["agents", "projects"]) await error(await api.get(apiPath(`/organizations/${missingId}/${child}`)), 404); +}); diff --git a/src/api/project.spec.ts b/src/api/project.spec.ts index e69de29..d134289 100644 --- a/src/api/project.spec.ts +++ b/src/api/project.spec.ts @@ -0,0 +1,25 @@ +import {test, expect, data, error, uniqueName} from "./fixtures"; +import {apiPath, missingId} from "./endpoints"; + +test("Project API creates and lists organization projects, reads and archives a project", async ({api}) => { + const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("project organization")}}), 201); + let project: {id: string} | undefined; + try { + const route = apiPath(`/organizations/${org.id}/projects`); + const name = uniqueName("project"); + await error(await api.post(route, {data: {name: ""}}), 422); + project = await data(await api.post(route, {data: {name}}), 201); + expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: project!.id, name})])); + expect(await data(await api.get(apiPath(`/projects/${project!.id}`)))).toMatchObject({id: project!.id, organizationId: org.id, name}); + await error(await api.post(route, {data: {name}}), 409); + await error(await api.delete(apiPath(`/organizations/${org.id}`)), 409); + } finally { + if (project) expect(await data(await api.delete(apiPath(`/projects/${project.id}`)))).toMatchObject({isArchived: true}); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); + } +}); + +test("Project API returns not found for unknown IDs", async ({api}) => { + await error(await api.get(apiPath(`/projects/${missingId}`)), 404); + await error(await api.delete(apiPath(`/projects/${missingId}`)), 404); +}); From 56198ef8a235596e9990bdeeed3b98cf191ca1fb Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Wed, 9 Sep 2026 11:07:09 +0200 Subject: [PATCH 03/11] refactor(api): reuse project test infrastructure --- docker/api/docker-compose.yml | 32 ----------------- docker/api/seed.sql | 2 -- playwright.config.ts | 23 ++++++------ src/api/README.md | 10 ++---- src/api/database.spec.ts | 67 ++++++++++++----------------------- 5 files changed, 35 insertions(+), 99 deletions(-) delete mode 100644 docker/api/docker-compose.yml delete mode 100644 docker/api/seed.sql diff --git a/docker/api/docker-compose.yml b/docker/api/docker-compose.yml deleted file mode 100644 index fd4220b..0000000 --- a/docker/api/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: portabase-api-e2e -services: - api-postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: api_e2e - POSTGRES_USER: backup - POSTGRES_PASSWORD: backup - healthcheck: - test: ["CMD-SHELL", "pg_isready -U backup -d api_e2e"] - interval: 2s - timeout: 2s - retries: 30 - volumes: - - ./seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro - networks: [portabase] - agent: - image: ${AGENT_IMAGE:-portabase/agent:latest} - environment: - EDGE_KEY: ${EDGE_KEY} - POLLING: 2 - TZ: UTC - volumes: - - ${API_AGENT_CONFIG:?API_AGENT_CONFIG is required}:/config/config.json:ro - depends_on: - api-postgres: - condition: service_healthy - extra_hosts: ["localhost:host-gateway"] - networks: [portabase] -networks: - portabase: - external: true diff --git a/docker/api/seed.sql b/docker/api/seed.sql deleted file mode 100644 index e5cf23a..0000000 --- a/docker/api/seed.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE TABLE e2e_restore_probe (id integer PRIMARY KEY, value text NOT NULL); -INSERT INTO e2e_restore_probe VALUES (1, 'original'); diff --git a/playwright.config.ts b/playwright.config.ts index d71f679..e0a6405 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; -import {existsSync} from "node:fs"; dotenv.config({ path: path.resolve(__dirname, ".env") }); @@ -22,45 +21,45 @@ export default defineConfig({ projects: [ { name: "setup", - testMatch: "**/setup.spec.ts", + testMatch: "/src/setup.spec.ts", }, ...(process.env.SKIP_ONBOARDING === "false" ? [ { name: "onboarding", - testMatch: "**/onboarding.spec.ts", + testMatch: "/src/onboarding.spec.ts", dependencies: ["setup"], }, ] : []), { name: "auth", - testMatch: "**/auth.spec.ts", + testMatch: "/src/auth.spec.ts", dependencies: ["setup"], }, { name: "oidc", - testMatch: "**/oidc.spec.ts", + testMatch: "/src/oidc.spec.ts", dependencies: ["auth"], }, { name: "access-management", - testMatch: "**/access-management.spec.ts", + testMatch: "/src/access-management.spec.ts", dependencies: ["oidc"], }, { name: "notification", - testMatch: "**/notification/**/*.spec.ts", + testMatch: "/src/notification/**/*.spec.ts", dependencies: ["access-management"], }, { name: "storage", - testMatch: "**/storage/**/*.spec.ts", + testMatch: "/src/storage/**/*.spec.ts", dependencies: ["access-management"], }, { name: "agent", - testMatch: /[\\/]src[\\/]agent\.spec\.ts$/, + testMatch: "/src/agent.spec.ts", dependencies: ["access-management"], }, { @@ -70,10 +69,8 @@ export default defineConfig({ }, { name: "api", - testMatch: /[\\/]src[\\/]api[\\/].*\.spec\.ts$/, - dependencies: ["project", ...(existsSync(path.resolve(__dirname, "src/dashboard.spec.ts")) ? ["dashboard"] : [])], - fullyParallel: false, - retries: 0, + testMatch: "/src/api/*.spec.ts", + dependencies: ["project"], }, { name: "cleanup", diff --git a/src/api/README.md b/src/api/README.md index 0de460f..b149a3f 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -3,8 +3,8 @@ Reference: https://portabase.io/docs/dashboard/api/introduction Enable `API_ENABLED=true` and `OPENAPI_ENABLED=true` (included in `docker/server/.env`). -The API project runs after the project tests, and after dashboard when that feature -branch is present, so temporary API resources do not affect the dashboard counters. +The API project runs after the project tests, so it reuses the agents and databases +already created by the E2E environment. The cleanup project waits for the API tests before revoking the shared UI session. Run the suite with `pnpm exec playwright test --project=api`. Against an already @@ -22,11 +22,7 @@ UI, and tests missing and invalid `x-api-key` headers on every operation. | organisation.spec.ts | GET/POST `/organizations/{id}/agents`, DELETE `/organizations/{id}/agents/{agentId}` | Attach, list, duplicate attachment, detach and invalid IDs | | project.spec.ts | GET/POST `/organizations/{id}/projects`, GET/DELETE `/projects/{id}` | Create, list, read, duplicate slug, archive; reject deleting an organization with a live project | | database.spec.ts | GET `/databases`, GET/PATCH `/databases/{id}`, PUT `/databases/{id}/backup-policy` | Discover agent source, read, attach/detach, save/clear cron and reject invalid input | -| database.spec.ts | GET `/databases/{id}/status`, GET/POST `/databases/{id}/backup`, GET `/databases/{id}/backup/{backupId}`, POST `/databases/{id}/restore` | Backup, poll success and storage records, mutate a SQL value, restore, verify original SQL data; invalid restore and missing backups | - -The database test owns a temporary organization, project, agent and PostgreSQL -container, defined in `docker/api`. It deletes only those resources on completion. -Its generated database ID and configuration are stored in a temporary directory. +| database.spec.ts | GET `/databases/{id}/status`, GET/POST `/databases/{id}/backup`, GET `/databases/{id}/backup/{backupId}`, POST `/databases/{id}/restore` | Backup an existing managed database, poll success and storage records, restore it; invalid restore and missing backups | The backup-policy request uses `schedule`, as accepted by the route implementation. The OpenAPI version originally inspected described this property as `backupPolicy`; diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts index 1a56756..bbf60cd 100644 --- a/src/api/database.spec.ts +++ b/src/api/database.spec.ts @@ -1,51 +1,33 @@ import {test, expect, data, error, uniqueName} from "./fixtures"; import {apiPath, missingId} from "./endpoints"; -import {execFileSync} from "node:child_process"; -import {mkdtempSync, writeFileSync, rmSync} from "node:fs"; -import {tmpdir} from "node:os"; -import path from "node:path"; -import {randomUUID} from "node:crypto"; -test("Database API assigns projects, updates schedules, backs up and restores actual data", async ({api}) => { - test.setTimeout(12 * 60_000); - const directory = mkdtempSync(path.join(tmpdir(), "portabase-api-")); - const config = path.join(directory, "databases.json"); - const generatedId = randomUUID(); - writeFileSync(config, JSON.stringify({databases: [{ - name: "API PostgreSQL", type: "postgresql", host: "api-postgres", port: 5432, - database: "api_e2e", username: "backup", password: "backup", generated_id: generatedId, - }]})); +test("Database API assigns projects, updates schedules, backs up and restores a managed database", async ({api}) => { + test.setTimeout(8 * 60_000); const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("database org")}}), 201); - let agent: any; let project: any; - let edgeKey = ""; - const compose = (...args: string[]) => execFileSync("docker", ["compose", "-f", "docker/api/docker-compose.yml", ...args], { - cwd: path.resolve(__dirname, "../.."), - env: {...process.env, EDGE_KEY: edgeKey, API_AGENT_CONFIG: config}, - encoding: "utf8", timeout: 240_000, stdio: ["ignore", "pipe", "pipe"], - }); + let database: any; + let attachedAgentId: string | undefined; + let originalProjectId: string | null = null; + let originalBackupPolicy: string | null = null; try { project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: uniqueName("database project")}}), 201); - agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("database agent"), organizationId: org.id}}), 201); - edgeKey = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); - compose("up", "-d"); - let database: any; - await expect(async () => { - const databases = await data(await api.get(apiPath("/databases"))); - database = databases.find(db => db.agentDatabaseId === generatedId); - expect(database?.lastContact).toBeTruthy(); - }).toPass({timeout: 90_000, intervals: [2_000]}); + const databases = await data(await api.get(apiPath("/databases"))); + database = databases.find(item => item.dbms === "postgresql" && item.lastContact && item.agentId); + expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); + attachedAgentId = database.agentId; + originalProjectId = database.projectId ?? null; + originalBackupPolicy = database.backupPolicy ?? null; + await data(await api.post(apiPath(`/organizations/${org.id}/agents`), {data: {agentId: attachedAgentId}}), 201); const route = apiPath(`/databases/${database.id}`); - await error(await api.get(route), 403); await error(await api.patch(route, {data: {projectId: "invalid"}}), 422); expect(await data(await api.patch(route, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); expect(await data(await api.patch(route, {data: {projectId: null}}))).toMatchObject({projectId: null}); await data(await api.patch(route, {data: {projectId: project.id}})); - expect(await data(await api.get(route))).toMatchObject({id: database.id, agentDatabaseId: generatedId, name: "API PostgreSQL"}); + expect(await data(await api.get(route))).toMatchObject({id: database.id, name: database.name}); await error(await api.put(`${route}/backup-policy`, {data: {schedule: "invalid"}}), 422); expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); - expect(await data(await api.get(`${route}/backup`))).toEqual([]); + expect(await data(await api.get(`${route}/backup`))).toEqual(expect.any(Array)); await error(await api.get(`${route}/backup/${missingId}`), 404); await error(await api.post(`${route}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); await error(await api.post(`${route}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); @@ -59,26 +41,21 @@ test("Database API assigns projects, updates schedules, backs up and restores ac }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); expect(await data(await api.get(`${route}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); expect(await data(await api.get(`${route}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); - const sql = (query: string) => compose("exec", "-T", "api-postgres", "psql", "-U", "backup", "-d", "api_e2e", "-Atc", query).trim(); - expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("original"); - sql("UPDATE e2e_restore_probe SET value = 'changed' WHERE id = 1"); - expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("changed"); const storage = completed.storages.find((item: any) => item.status === "success"); const restore = await data(await api.post(`${route}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); await expect(async () => { expect(await data(await api.get(`${route}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); - expect(sql("SELECT value FROM e2e_restore_probe WHERE id = 1")).toBe("original"); } finally { - try { - compose("down", "--volumes"); - } finally { - if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); - if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - rmSync(directory, {recursive: true, force: true}); + if (database) { + const route = apiPath(`/databases/${database.id}`); + await data(await api.put(`${route}/backup-policy`, {data: {schedule: originalBackupPolicy ?? ""}})); + await data(await api.patch(route, {data: {projectId: originalProjectId}})); } + if (attachedAgentId) await data(await api.delete(apiPath(`/organizations/${org.id}/agents/${attachedAgentId}`))); + if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); } }); From 1fd9a5400ec4dec3b0fa272ad40940742ba38c0a Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Wed, 9 Sep 2026 12:37:54 +0200 Subject: [PATCH 04/11] test(api): run endpoint coverage serially --- src/api/agent.spec.ts | 46 ++++++------- src/api/contract.spec.ts | 52 ++++++++------- src/api/database.spec.ts | 122 ++++++++++++++++++----------------- src/api/organisation.spec.ts | 58 +++++++++-------- src/api/project.spec.ts | 42 ++++++------ 5 files changed, 165 insertions(+), 155 deletions(-) diff --git a/src/api/agent.spec.ts b/src/api/agent.spec.ts index 227d15d..b4366fd 100644 --- a/src/api/agent.spec.ts +++ b/src/api/agent.spec.ts @@ -1,27 +1,29 @@ import {test, expect, data, error, uniqueName} from "./fixtures"; import {apiPath, missingId} from "./endpoints"; -test("Agent API creates, lists, reads, retrieves an edge key and deletes an agent", async ({api}) => { - const name = uniqueName("agent"); - const agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); - expect(agent).toMatchObject({id: expect.any(String), name}); - try { - expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id, name})])); - expect(await data(await api.get(apiPath(`/agents/${agent.id}`)))).toMatchObject({id: agent.id, name}); - const key = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); - expect(key.length).toBeGreaterThan(0); - } finally { - const deleted = await api.delete(apiPath(`/agents/${agent.id}`)); - expect(deleted.status()).toBe(204); - expect(await deleted.body()).toHaveLength(0); - } - await error(await api.get(apiPath(`/agents/${agent.id}`)), 404); - expect(await data(await api.get(apiPath("/agents")))).not.toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id})])); -}); +test.describe.serial(() => { + test("Agent API creates, lists, reads, retrieves an edge key and deletes an agent", async ({api}) => { + const name = uniqueName("agent"); + const agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); + expect(agent).toMatchObject({id: expect.any(String), name}); + try { + expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id, name})])); + expect(await data(await api.get(apiPath(`/agents/${agent.id}`)))).toMatchObject({id: agent.id, name}); + const key = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); + expect(key.length).toBeGreaterThan(0); + } finally { + const deleted = await api.delete(apiPath(`/agents/${agent.id}`)); + expect(deleted.status()).toBe(204); + expect(await deleted.body()).toHaveLength(0); + } + await error(await api.get(apiPath(`/agents/${agent.id}`)), 404); + expect(await data(await api.get(apiPath("/agents")))).not.toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id})])); + }); -test("Agent API validates payloads and unknown IDs", async ({api}) => { - await error(await api.post(apiPath("/agents"), {data: {name: ""}}), 422); - await error(await api.post(apiPath("/agents"), {data: "{", headers: {"Content-Type": "application/json"}}), 422); - for (const suffix of ["", "/key"]) await error(await api.get(apiPath(`/agents/${missingId}${suffix}`)), 404); - await error(await api.delete(apiPath(`/agents/${missingId}`)), 404); + test("Agent API validates payloads and unknown IDs", async ({api}) => { + await error(await api.post(apiPath("/agents"), {data: {name: ""}}), 422); + await error(await api.post(apiPath("/agents"), {data: "{", headers: {"Content-Type": "application/json"}}), 422); + for (const suffix of ["", "/key"]) await error(await api.get(apiPath(`/agents/${missingId}${suffix}`)), 404); + await error(await api.delete(apiPath(`/agents/${missingId}`)), 404); + }); }); diff --git a/src/api/contract.spec.ts b/src/api/contract.spec.ts index bbd8b34..eac1801 100644 --- a/src/api/contract.spec.ts +++ b/src/api/contract.spec.ts @@ -1,31 +1,33 @@ import {test, expect} from "@playwright/test"; import {apiPath, endpoints, missingId} from "./endpoints"; -test("OpenAPI exposes every mapped REST operation and API-key authentication", async ({request}) => { - const response = await request.get(apiPath("/openapi")); - expect(response.status()).toBe(200); - const spec = await response.json(); - expect(spec.openapi).toMatch(/^3\./); - expect(spec.components.securitySchemes.apiKeyAuth).toMatchObject({type: "apiKey", in: "header", name: "x-api-key"}); - const actual = Object.entries(spec.paths).flatMap(([path, item]) => Object.keys(item as object) - .filter(method => ["get", "post", "put", "patch", "delete"].includes(method)) - .map(method => `${method.toUpperCase()} ${path}`)); - expect(actual.sort()).toEqual(endpoints.map(([method, path]) => `${method} ${path}`).sort()); - const docs = await request.get(apiPath("/docs")); - expect(docs.status()).toBe(200); - expect(await docs.text()).toContain("swagger"); -}); +test.describe.serial(() => { + test("OpenAPI exposes every mapped REST operation and API-key authentication", async ({request}) => { + const response = await request.get(apiPath("/openapi")); + expect(response.status()).toBe(200); + const spec = await response.json(); + expect(spec.openapi).toMatch(/^3\./); + expect(spec.components.securitySchemes.apiKeyAuth).toMatchObject({type: "apiKey", in: "header", name: "x-api-key"}); + const actual = Object.entries(spec.paths).flatMap(([path, item]) => Object.keys(item as object) + .filter(method => ["get", "post", "put", "patch", "delete"].includes(method)) + .map(method => `${method.toUpperCase()} ${path}`)); + expect(actual.sort()).toEqual(endpoints.map(([method, path]) => `${method} ${path}`).sort()); + const docs = await request.get(apiPath("/docs")); + expect(docs.status()).toBe(200); + expect(await docs.text()).toContain("swagger"); + }); -for (const [method, template] of endpoints) { - for (const key of [undefined, "invalid-e2e-api-key"]) { - test(`${method} ${template} rejects ${key ? "invalid" : "missing"} API key`, async ({request}) => { - const response = await request.fetch(apiPath(template.replace(/\{\w+\}/g, missingId)), { - method, - headers: key ? {"x-api-key": key} : {}, - ...(["POST", "PUT", "PATCH"].includes(method) ? {data: {}} : {}), + for (const [method, template] of endpoints) { + for (const key of [undefined, "invalid-e2e-api-key"]) { + test(`${method} ${template} rejects ${key ? "invalid" : "missing"} API key`, async ({request}) => { + const response = await request.fetch(apiPath(template.replace(/\{\w+\}/g, missingId)), { + method, + headers: key ? {"x-api-key": key} : {}, + ...(["POST", "PUT", "PATCH"].includes(method) ? {data: {}} : {}), + }); + expect(response.status()).toBe(401); + expect(await response.json()).toMatchObject({error: expect.any(String)}); }); - expect(response.status()).toBe(401); - expect(await response.json()).toMatchObject({error: expect.any(String)}); - }); + } } -} +}); diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts index bbf60cd..304e221 100644 --- a/src/api/database.spec.ts +++ b/src/api/database.spec.ts @@ -1,67 +1,69 @@ import {test, expect, data, error, uniqueName} from "./fixtures"; import {apiPath, missingId} from "./endpoints"; -test("Database API assigns projects, updates schedules, backs up and restores a managed database", async ({api}) => { - test.setTimeout(8 * 60_000); - const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("database org")}}), 201); - let project: any; - let database: any; - let attachedAgentId: string | undefined; - let originalProjectId: string | null = null; - let originalBackupPolicy: string | null = null; - try { - project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: uniqueName("database project")}}), 201); - const databases = await data(await api.get(apiPath("/databases"))); - database = databases.find(item => item.dbms === "postgresql" && item.lastContact && item.agentId); - expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); - attachedAgentId = database.agentId; - originalProjectId = database.projectId ?? null; - originalBackupPolicy = database.backupPolicy ?? null; - await data(await api.post(apiPath(`/organizations/${org.id}/agents`), {data: {agentId: attachedAgentId}}), 201); - const route = apiPath(`/databases/${database.id}`); - await error(await api.patch(route, {data: {projectId: "invalid"}}), 422); - expect(await data(await api.patch(route, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); - expect(await data(await api.patch(route, {data: {projectId: null}}))).toMatchObject({projectId: null}); - await data(await api.patch(route, {data: {projectId: project.id}})); - expect(await data(await api.get(route))).toMatchObject({id: database.id, name: database.name}); - await error(await api.put(`${route}/backup-policy`, {data: {schedule: "invalid"}}), 422); - expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); - expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); - expect(await data(await api.get(`${route}/backup`))).toEqual(expect.any(Array)); - await error(await api.get(`${route}/backup/${missingId}`), 404); - await error(await api.post(`${route}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); - await error(await api.post(`${route}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); - const backup = await data(await api.post(`${route}/backup`), 201); - expect(backup).toMatchObject({databaseId: database.id, status: "waiting"}); - let completed: any; - await expect(async () => { - completed = await data(await api.get(`${route}/backup/${backup.id}`)); - expect(completed.status).toBe("success"); - expect(completed.storages).toEqual(expect.arrayContaining([expect.objectContaining({status: "success"})])); - }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); - expect(await data(await api.get(`${route}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); - expect(await data(await api.get(`${route}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); - const storage = completed.storages.find((item: any) => item.status === "success"); - const restore = await data(await api.post(`${route}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); - expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); - await expect(async () => { - expect(await data(await api.get(`${route}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); - }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); - } finally { - if (database) { +test.describe.serial(() => { + test("Database API assigns projects, updates schedules, backs up and restores a managed database", async ({api}) => { + test.setTimeout(8 * 60_000); + const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("database org")}}), 201); + let project: any; + let database: any; + let attachedAgentId: string | undefined; + let originalProjectId: string | null = null; + let originalBackupPolicy: string | null = null; + try { + project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: uniqueName("database project")}}), 201); + const databases = await data(await api.get(apiPath("/databases"))); + database = databases.find(item => item.dbms === "postgresql" && item.lastContact && item.agentId); + expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); + attachedAgentId = database.agentId; + originalProjectId = database.projectId ?? null; + originalBackupPolicy = database.backupPolicy ?? null; + await data(await api.post(apiPath(`/organizations/${org.id}/agents`), {data: {agentId: attachedAgentId}}), 201); const route = apiPath(`/databases/${database.id}`); - await data(await api.put(`${route}/backup-policy`, {data: {schedule: originalBackupPolicy ?? ""}})); - await data(await api.patch(route, {data: {projectId: originalProjectId}})); + await error(await api.patch(route, {data: {projectId: "invalid"}}), 422); + expect(await data(await api.patch(route, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); + expect(await data(await api.patch(route, {data: {projectId: null}}))).toMatchObject({projectId: null}); + await data(await api.patch(route, {data: {projectId: project.id}})); + expect(await data(await api.get(route))).toMatchObject({id: database.id, name: database.name}); + await error(await api.put(`${route}/backup-policy`, {data: {schedule: "invalid"}}), 422); + expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); + expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); + expect(await data(await api.get(`${route}/backup`))).toEqual(expect.any(Array)); + await error(await api.get(`${route}/backup/${missingId}`), 404); + await error(await api.post(`${route}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); + await error(await api.post(`${route}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); + const backup = await data(await api.post(`${route}/backup`), 201); + expect(backup).toMatchObject({databaseId: database.id, status: "waiting"}); + let completed: any; + await expect(async () => { + completed = await data(await api.get(`${route}/backup/${backup.id}`)); + expect(completed.status).toBe("success"); + expect(completed.storages).toEqual(expect.arrayContaining([expect.objectContaining({status: "success"})])); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + expect(await data(await api.get(`${route}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); + expect(await data(await api.get(`${route}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); + const storage = completed.storages.find((item: any) => item.status === "success"); + const restore = await data(await api.post(`${route}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); + expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); + await expect(async () => { + expect(await data(await api.get(`${route}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + } finally { + if (database) { + const route = apiPath(`/databases/${database.id}`); + await data(await api.put(`${route}/backup-policy`, {data: {schedule: originalBackupPolicy ?? ""}})); + await data(await api.patch(route, {data: {projectId: originalProjectId}})); + } + if (attachedAgentId) await data(await api.delete(apiPath(`/organizations/${org.id}/agents/${attachedAgentId}`))); + if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); } - if (attachedAgentId) await data(await api.delete(apiPath(`/organizations/${org.id}/agents/${attachedAgentId}`))); - if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } -}); + }); -test("Database API rejects unknown resources", async ({api}) => { - for (const suffix of ["", "/status", "/backup", `/backup/${missingId}`]) { - await error(await api.get(apiPath(`/databases/${missingId}${suffix}`)), 404); - } - await error(await api.post(apiPath(`/databases/${missingId}/backup`)), 404); + test("Database API rejects unknown resources", async ({api}) => { + for (const suffix of ["", "/status", "/backup", `/backup/${missingId}`]) { + await error(await api.get(apiPath(`/databases/${missingId}${suffix}`)), 404); + } + await error(await api.post(apiPath(`/databases/${missingId}/backup`)), 404); + }); }); diff --git a/src/api/organisation.spec.ts b/src/api/organisation.spec.ts index 99f4159..bb76bbb 100644 --- a/src/api/organisation.spec.ts +++ b/src/api/organisation.spec.ts @@ -1,33 +1,35 @@ import {test, expect, data, error, uniqueName} from "./fixtures"; import {apiPath, missingId} from "./endpoints"; -test("Organization API creates, lists, reads, attaches and detaches an agent, and deletes", async ({api}) => { - const name = uniqueName("organization"); - const org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); - let agent: {id: string} | undefined; - try { - expect(org).toMatchObject({id: expect.any(String), name}); - expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([expect.objectContaining({id: org.id})])); - expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); - await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); - agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("attached agent")}}), 201); - const route = apiPath(`/organizations/${org.id}/agents`); - expect(await data(await api.post(route, {data: {agentId: agent!.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent!.id}); - await error(await api.post(route, {data: {agentId: agent!.id}}), 422); - expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent!.id})])); - expect(await data(await api.delete(`${route}/${agent!.id}`))).toEqual({organizationId: org.id, agentId: agent!.id}); - expect(await data(await api.get(route))).toEqual([]); - await error(await api.post(route, {data: {agentId: "invalid"}}), 422); - } finally { - if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } - await error(await api.get(apiPath(`/organizations/${org.id}`)), 404); -}); +test.describe.serial(() => { + test("Organization API creates, lists, reads, attaches and detaches an agent, and deletes", async ({api}) => { + const name = uniqueName("organization"); + const org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); + let agent: {id: string} | undefined; + try { + expect(org).toMatchObject({id: expect.any(String), name}); + expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([expect.objectContaining({id: org.id})])); + expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); + await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); + agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("attached agent")}}), 201); + const route = apiPath(`/organizations/${org.id}/agents`); + expect(await data(await api.post(route, {data: {agentId: agent!.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent!.id}); + await error(await api.post(route, {data: {agentId: agent!.id}}), 422); + expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent!.id})])); + expect(await data(await api.delete(`${route}/${agent!.id}`))).toEqual({organizationId: org.id, agentId: agent!.id}); + expect(await data(await api.get(route))).toEqual([]); + await error(await api.post(route, {data: {agentId: "invalid"}}), 422); + } finally { + if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); + } + await error(await api.get(apiPath(`/organizations/${org.id}`)), 404); + }); -test("Organization API rejects invalid input and unknown resources", async ({api}) => { - await error(await api.post(apiPath("/organizations"), {data: {name: ""}}), 422); - await error(await api.get(apiPath(`/organizations/${missingId}`)), 404); - await error(await api.delete(apiPath(`/organizations/${missingId}`)), 404); - for (const child of ["agents", "projects"]) await error(await api.get(apiPath(`/organizations/${missingId}/${child}`)), 404); + test("Organization API rejects invalid input and unknown resources", async ({api}) => { + await error(await api.post(apiPath("/organizations"), {data: {name: ""}}), 422); + await error(await api.get(apiPath(`/organizations/${missingId}`)), 404); + await error(await api.delete(apiPath(`/organizations/${missingId}`)), 404); + for (const child of ["agents", "projects"]) await error(await api.get(apiPath(`/organizations/${missingId}/${child}`)), 404); + }); }); diff --git a/src/api/project.spec.ts b/src/api/project.spec.ts index d134289..4c85821 100644 --- a/src/api/project.spec.ts +++ b/src/api/project.spec.ts @@ -1,25 +1,27 @@ import {test, expect, data, error, uniqueName} from "./fixtures"; import {apiPath, missingId} from "./endpoints"; -test("Project API creates and lists organization projects, reads and archives a project", async ({api}) => { - const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("project organization")}}), 201); - let project: {id: string} | undefined; - try { - const route = apiPath(`/organizations/${org.id}/projects`); - const name = uniqueName("project"); - await error(await api.post(route, {data: {name: ""}}), 422); - project = await data(await api.post(route, {data: {name}}), 201); - expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: project!.id, name})])); - expect(await data(await api.get(apiPath(`/projects/${project!.id}`)))).toMatchObject({id: project!.id, organizationId: org.id, name}); - await error(await api.post(route, {data: {name}}), 409); - await error(await api.delete(apiPath(`/organizations/${org.id}`)), 409); - } finally { - if (project) expect(await data(await api.delete(apiPath(`/projects/${project.id}`)))).toMatchObject({isArchived: true}); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } -}); +test.describe.serial(() => { + test("Project API creates and lists organization projects, reads and archives a project", async ({api}) => { + const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("project organization")}}), 201); + let project: {id: string} | undefined; + try { + const route = apiPath(`/organizations/${org.id}/projects`); + const name = uniqueName("project"); + await error(await api.post(route, {data: {name: ""}}), 422); + project = await data(await api.post(route, {data: {name}}), 201); + expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: project!.id, name})])); + expect(await data(await api.get(apiPath(`/projects/${project!.id}`)))).toMatchObject({id: project!.id, organizationId: org.id, name}); + await error(await api.post(route, {data: {name}}), 409); + await error(await api.delete(apiPath(`/organizations/${org.id}`)), 409); + } finally { + if (project) expect(await data(await api.delete(apiPath(`/projects/${project.id}`)))).toMatchObject({isArchived: true}); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); + } + }); -test("Project API returns not found for unknown IDs", async ({api}) => { - await error(await api.get(apiPath(`/projects/${missingId}`)), 404); - await error(await api.delete(apiPath(`/projects/${missingId}`)), 404); + test("Project API returns not found for unknown IDs", async ({api}) => { + await error(await api.get(apiPath(`/projects/${missingId}`)), 404); + await error(await api.delete(apiPath(`/projects/${missingId}`)), 404); + }); }); From 3c9486367cca6564f4c0e81a3e8e982ef39e6fb7 Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Wed, 9 Sep 2026 15:58:56 +0200 Subject: [PATCH 05/11] test(api): share one UI-created API key --- playwright.config.ts | 7 ++++++- src/api/README.md | 8 ++++---- src/api/agent.spec.ts | 12 +++++++++--- src/api/api-key.setup.ts | 8 ++++++++ src/api/contract.spec.ts | 18 +++++++++++++++++- src/api/database.spec.ts | 12 +++++++----- src/api/endpoints.ts | 17 ----------------- src/api/fixtures.ts | 32 +++++++++++++++++--------------- src/api/organisation.spec.ts | 14 ++++++++++---- src/api/project.spec.ts | 16 ++++++++++++---- 10 files changed, 90 insertions(+), 54 deletions(-) create mode 100644 src/api/api-key.setup.ts delete mode 100644 src/api/endpoints.ts diff --git a/playwright.config.ts b/playwright.config.ts index e0a6405..bc432a5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -67,10 +67,15 @@ export default defineConfig({ testMatch: /[\\/]src[\\/]project\.spec\.ts$/, dependencies: ["agent"], }, + { + name: "api-setup", + testMatch: "/src/api/api-key.setup.ts", + dependencies: ["notification", "storage", "project"], + }, { name: "api", testMatch: "/src/api/*.spec.ts", - dependencies: ["project"], + dependencies: ["api-setup"], }, { name: "cleanup", diff --git a/src/api/README.md b/src/api/README.md index b149a3f..ba12622 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -3,14 +3,14 @@ Reference: https://portabase.io/docs/dashboard/api/introduction Enable `API_ENABLED=true` and `OPENAPI_ENABLED=true` (included in `docker/server/.env`). -The API project runs after the project tests, so it reuses the agents and databases -already created by the E2E environment. +The API project runs after the notification, storage and project tests, and checks +the organizations, agents, projects and databases created through the UI. The cleanup project waits for the API tests before revoking the shared UI session. Run the suite with `pnpm exec playwright test --project=api`. Against an already initialized E2E environment and saved authenticated session, add `--no-deps`. -API keys are created through Account Settings, kept in memory, and revoked at the -end of each worker. No external account or manual token configuration is required. +One API key is created through Account Settings, stored in `test-results/api-key.json` +and reused by every API test. No external account or manual token is required. `contract.spec.ts` compares all 25 operations with `/api/v1/openapi`, checks Swagger UI, and tests missing and invalid `x-api-key` headers on every operation. diff --git a/src/api/agent.spec.ts b/src/api/agent.spec.ts index b4366fd..cbdadb0 100644 --- a/src/api/agent.spec.ts +++ b/src/api/agent.spec.ts @@ -1,9 +1,15 @@ -import {test, expect, data, error, uniqueName} from "./fixtures"; -import {apiPath, missingId} from "./endpoints"; +import {test, expect, data, error} from "./fixtures"; + +const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +const apiPath = (path: string) => `/api/v1${path}`; test.describe.serial(() => { test("Agent API creates, lists, reads, retrieves an edge key and deletes an agent", async ({api}) => { - const name = uniqueName("agent"); + expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([ + expect.objectContaining({name: "Agent A Updated"}), + expect.objectContaining({name: "Agent B"}), + ])); + const name = "API Agent A"; const agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); expect(agent).toMatchObject({id: expect.any(String), name}); try { diff --git a/src/api/api-key.setup.ts b/src/api/api-key.setup.ts new file mode 100644 index 0000000..50112fd --- /dev/null +++ b/src/api/api-key.setup.ts @@ -0,0 +1,8 @@ +import {test} from "@playwright/test"; +import {createApiKey} from "./fixtures"; + +test.describe.serial(() => { + test("Create shared API key", async ({browser}) => { + await createApiKey(browser); + }); +}); diff --git a/src/api/contract.spec.ts b/src/api/contract.spec.ts index eac1801..4518faa 100644 --- a/src/api/contract.spec.ts +++ b/src/api/contract.spec.ts @@ -1,5 +1,21 @@ import {test, expect} from "@playwright/test"; -import {apiPath, endpoints, missingId} from "./endpoints"; + +const endpoints = [ + ["GET", "/agents"], ["POST", "/agents"], + ["GET", "/agents/{id}"], ["DELETE", "/agents/{id}"], ["GET", "/agents/{id}/key"], + ["GET", "/databases"], ["GET", "/databases/{id}"], ["PATCH", "/databases/{id}"], + ["GET", "/databases/{id}/status"], ["GET", "/databases/{id}/backup"], ["POST", "/databases/{id}/backup"], + ["GET", "/databases/{id}/backup/{backupId}"], ["POST", "/databases/{id}/restore"], + ["PUT", "/databases/{id}/backup-policy"], + ["GET", "/organizations"], ["POST", "/organizations"], + ["GET", "/organizations/{id}"], ["DELETE", "/organizations/{id}"], + ["GET", "/organizations/{id}/projects"], ["POST", "/organizations/{id}/projects"], + ["GET", "/organizations/{id}/agents"], ["POST", "/organizations/{id}/agents"], + ["DELETE", "/organizations/{id}/agents/{agentId}"], + ["GET", "/projects/{id}"], ["DELETE", "/projects/{id}"], +] as const; +const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +const apiPath = (path: string) => `/api/v1${path}`; test.describe.serial(() => { test("OpenAPI exposes every mapped REST operation and API-key authentication", async ({request}) => { diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts index 304e221..cc9670b 100644 --- a/src/api/database.spec.ts +++ b/src/api/database.spec.ts @@ -1,19 +1,21 @@ -import {test, expect, data, error, uniqueName} from "./fixtures"; -import {apiPath, missingId} from "./endpoints"; +import {test, expect, data, error} from "./fixtures"; + +const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +const apiPath = (path: string) => `/api/v1${path}`; test.describe.serial(() => { test("Database API assigns projects, updates schedules, backs up and restores a managed database", async ({api}) => { test.setTimeout(8 * 60_000); - const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("database org")}}), 201); + const org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Database Organization A"}}), 201); let project: any; let database: any; let attachedAgentId: string | undefined; let originalProjectId: string | null = null; let originalBackupPolicy: string | null = null; try { - project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: uniqueName("database project")}}), 201); + project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: "API Database Project A"}}), 201); const databases = await data(await api.get(apiPath("/databases"))); - database = databases.find(item => item.dbms === "postgresql" && item.lastContact && item.agentId); + database = databases.find(item => item.name === "PostgreSQL 18" && item.lastContact && item.agentId); expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); attachedAgentId = database.agentId; originalProjectId = database.projectId ?? null; diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts deleted file mode 100644 index c879ec7..0000000 --- a/src/api/endpoints.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const endpoints = [ - ["GET", "/agents"], ["POST", "/agents"], - ["GET", "/agents/{id}"], ["DELETE", "/agents/{id}"], ["GET", "/agents/{id}/key"], - ["GET", "/databases"], ["GET", "/databases/{id}"], ["PATCH", "/databases/{id}"], - ["GET", "/databases/{id}/status"], ["GET", "/databases/{id}/backup"], ["POST", "/databases/{id}/backup"], - ["GET", "/databases/{id}/backup/{backupId}"], ["POST", "/databases/{id}/restore"], - ["PUT", "/databases/{id}/backup-policy"], - ["GET", "/organizations"], ["POST", "/organizations"], - ["GET", "/organizations/{id}"], ["DELETE", "/organizations/{id}"], - ["GET", "/organizations/{id}/projects"], ["POST", "/organizations/{id}/projects"], - ["GET", "/organizations/{id}/agents"], ["POST", "/organizations/{id}/agents"], - ["DELETE", "/organizations/{id}/agents/{agentId}"], - ["GET", "/projects/{id}"], ["DELETE", "/projects/{id}"], -] as const; - -export const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; -export const apiPath = (path: string) => `/api/v1${path}`; diff --git a/src/api/fixtures.ts b/src/api/fixtures.ts index 683ce05..63c9bdc 100644 --- a/src/api/fixtures.ts +++ b/src/api/fixtures.ts @@ -1,9 +1,11 @@ -import {test as base, expect, APIRequestContext, APIResponse} from "@playwright/test"; +import {test as base, expect, APIRequestContext, APIResponse, Browser} from "@playwright/test"; import {randomUUID} from "node:crypto"; +import {mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import {dirname} from "node:path"; import {LOCAL_STORAGE_PATH} from "../helpers/session"; export {expect}; -export const uniqueName = (kind: string) => `E2E API ${kind} ${randomUUID()}`; +const API_KEY_PATH = "./test-results/api-key.json"; export async function data(response: APIResponse, status = 200): Promise { expect(response.status(), `${response.url()}: ${await response.text()}`).toBe(status); @@ -17,9 +19,9 @@ export async function error(response: APIResponse, status: number) { expect(await response.json()).toMatchObject({error: expect.any(String)}); } -export const test = base.extend<{api: APIRequestContext}, {apiKey: string}>({ - apiKey: [async ({browser}, use) => { - const context = await browser.newContext({storageState: LOCAL_STORAGE_PATH, baseURL: process.env.SERVER_URL}); +export async function createApiKey(browser: Browser) { + const context = await browser.newContext({storageState: LOCAL_STORAGE_PATH, baseURL: process.env.SERVER_URL}); + try { const page = await context.newPage(); const name = `e2e-${randomUUID().slice(0, 12)}`; await page.goto("/dashboard/home"); @@ -34,16 +36,16 @@ export const test = base.extend<{api: APIRequestContext}, {apiKey: string}>({ const key = await keyDialog.locator("input[readonly]").inputValue(); expect(key.length).toBeGreaterThan(10); await page.getByRole("button", {name: "I copied my API Key", exact: true}).click(); - try { - await use(key); - } finally { - const row = page.locator("div.flex.items-center.justify-between.p-4").filter({hasText: name}); - await row.getByRole("button", {name: "Revoke", exact: true}).click(); - await expect(row).toHaveCount(0); - await context.close(); - } - }, {scope: "worker"}], - api: async ({playwright, apiKey}, use) => { + mkdirSync(dirname(API_KEY_PATH), {recursive: true}); + writeFileSync(API_KEY_PATH, JSON.stringify({apiKey: key})); + } finally { + await context.close(); + } +} + +export const test = base.extend<{api: APIRequestContext}>({ + api: async ({playwright}, use) => { + const {apiKey} = JSON.parse(readFileSync(API_KEY_PATH, "utf8")); const api = await playwright.request.newContext({ baseURL: process.env.SERVER_URL, extraHTTPHeaders: {"x-api-key": apiKey}, diff --git a/src/api/organisation.spec.ts b/src/api/organisation.spec.ts index bb76bbb..2a5d7f8 100644 --- a/src/api/organisation.spec.ts +++ b/src/api/organisation.spec.ts @@ -1,9 +1,15 @@ -import {test, expect, data, error, uniqueName} from "./fixtures"; -import {apiPath, missingId} from "./endpoints"; +import {test, expect, data, error} from "./fixtures"; + +const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +const apiPath = (path: string) => `/api/v1${path}`; test.describe.serial(() => { test("Organization API creates, lists, reads, attaches and detaches an agent, and deletes", async ({api}) => { - const name = uniqueName("organization"); + expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([ + expect.objectContaining({name: "Organization A"}), + expect.objectContaining({name: "Organization B"}), + ])); + const name = "API Organization A"; const org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); let agent: {id: string} | undefined; try { @@ -11,7 +17,7 @@ test.describe.serial(() => { expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([expect.objectContaining({id: org.id})])); expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); - agent = await data(await api.post(apiPath("/agents"), {data: {name: uniqueName("attached agent")}}), 201); + agent = await data(await api.post(apiPath("/agents"), {data: {name: "API Attached Agent A"}}), 201); const route = apiPath(`/organizations/${org.id}/agents`); expect(await data(await api.post(route, {data: {agentId: agent!.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent!.id}); await error(await api.post(route, {data: {agentId: agent!.id}}), 422); diff --git a/src/api/project.spec.ts b/src/api/project.spec.ts index 4c85821..9a83626 100644 --- a/src/api/project.spec.ts +++ b/src/api/project.spec.ts @@ -1,13 +1,21 @@ -import {test, expect, data, error, uniqueName} from "./fixtures"; -import {apiPath, missingId} from "./endpoints"; +import {test, expect, data, error} from "./fixtures"; + +const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; +const apiPath = (path: string) => `/api/v1${path}`; test.describe.serial(() => { test("Project API creates and lists organization projects, reads and archives a project", async ({api}) => { - const org = await data(await api.post(apiPath("/organizations"), {data: {name: uniqueName("project organization")}}), 201); + const organizations = await data(await api.get(apiPath("/organizations"))); + const defaultOrganization = organizations.find(organization => organization.name === "Default Organization"); + expect(defaultOrganization).toBeTruthy(); + expect(await data(await api.get(apiPath(`/organizations/${defaultOrganization.id}/projects`)))).toEqual( + expect.arrayContaining([expect.objectContaining({name: "Project A"})]), + ); + const org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Project Organization A"}}), 201); let project: {id: string} | undefined; try { const route = apiPath(`/organizations/${org.id}/projects`); - const name = uniqueName("project"); + const name = "API Project A"; await error(await api.post(route, {data: {name: ""}}), 422); project = await data(await api.post(route, {data: {name}}), 201); expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: project!.id, name})])); From b6e25d2e5dfbf9b1c1ba98b5be54c567b526e656 Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Thu, 10 Sep 2026 10:46:24 +0200 Subject: [PATCH 06/11] test(api): share key through session path --- .gitignore | 1 + playwright.config.ts | 2 ++ src/api/README.md | 2 +- src/api/fixtures.ts | 7 ++----- src/cleanup.spec.ts | 5 ++++- src/helpers/session.ts | 1 + src/setup.spec.ts | 3 ++- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 171cff1..da35fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .idea playwright-report test-results +/src/api-key.json docs diff --git a/playwright.config.ts b/playwright.config.ts index bc432a5..24845fd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -76,6 +76,8 @@ export default defineConfig({ name: "api", testMatch: "/src/api/*.spec.ts", dependencies: ["api-setup"], + fullyParallel: false, + workers: 1, }, { name: "cleanup", diff --git a/src/api/README.md b/src/api/README.md index ba12622..4ea1bfb 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -9,7 +9,7 @@ The cleanup project waits for the API tests before revoking the shared UI sessio Run the suite with `pnpm exec playwright test --project=api`. Against an already initialized E2E environment and saved authenticated session, add `--no-deps`. -One API key is created through Account Settings, stored in `test-results/api-key.json` +One API key is created through Account Settings, stored in `src/api-key.json` and reused by every API test. No external account or manual token is required. `contract.spec.ts` compares all 25 operations with `/api/v1/openapi`, checks Swagger diff --git a/src/api/fixtures.ts b/src/api/fixtures.ts index 63c9bdc..1dfccc8 100644 --- a/src/api/fixtures.ts +++ b/src/api/fixtures.ts @@ -1,11 +1,9 @@ import {test as base, expect, APIRequestContext, APIResponse, Browser} from "@playwright/test"; import {randomUUID} from "node:crypto"; -import {mkdirSync, readFileSync, writeFileSync} from "node:fs"; -import {dirname} from "node:path"; -import {LOCAL_STORAGE_PATH} from "../helpers/session"; +import {readFileSync, writeFileSync} from "node:fs"; +import {API_KEY_PATH, LOCAL_STORAGE_PATH} from "../helpers/session"; export {expect}; -const API_KEY_PATH = "./test-results/api-key.json"; export async function data(response: APIResponse, status = 200): Promise { expect(response.status(), `${response.url()}: ${await response.text()}`).toBe(status); @@ -36,7 +34,6 @@ export async function createApiKey(browser: Browser) { const key = await keyDialog.locator("input[readonly]").inputValue(); expect(key.length).toBeGreaterThan(10); await page.getByRole("button", {name: "I copied my API Key", exact: true}).click(); - mkdirSync(dirname(API_KEY_PATH), {recursive: true}); writeFileSync(API_KEY_PATH, JSON.stringify({apiKey: key})); } finally { await context.close(); diff --git a/src/cleanup.spec.ts b/src/cleanup.spec.ts index bb865ea..5491cbb 100644 --- a/src/cleanup.spec.ts +++ b/src/cleanup.spec.ts @@ -1,7 +1,7 @@ import {expect, test} from "@playwright/test"; import * as fs from "fs"; import {logout} from "./helpers/auth"; -import {LOCAL_STORAGE_PATH} from "./helpers/session"; +import {API_KEY_PATH, LOCAL_STORAGE_PATH} from "./helpers/session"; test.use({storageState: LOCAL_STORAGE_PATH}); @@ -11,6 +11,9 @@ test.describe( () => { if (fs.existsSync(LOCAL_STORAGE_PATH)) { fs.unlinkSync(LOCAL_STORAGE_PATH); } + if (fs.existsSync(API_KEY_PATH)) { + fs.unlinkSync(API_KEY_PATH); + } }); test("Remove shared storage state", async ({page}) => { diff --git a/src/helpers/session.ts b/src/helpers/session.ts index cf96120..8a50c3d 100644 --- a/src/helpers/session.ts +++ b/src/helpers/session.ts @@ -1 +1,2 @@ export const LOCAL_STORAGE_PATH = "./src/local-storage.json"; +export const API_KEY_PATH = "./src/api-key.json"; diff --git a/src/setup.spec.ts b/src/setup.spec.ts index 9c7348c..a08b40b 100644 --- a/src/setup.spec.ts +++ b/src/setup.spec.ts @@ -1,10 +1,11 @@ import {test} from "@playwright/test"; import * as fs from "fs"; -import {LOCAL_STORAGE_PATH} from "./helpers/session"; +import {API_KEY_PATH, LOCAL_STORAGE_PATH} from "./helpers/session"; test.describe(() => { test.beforeAll(async () => { if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH); + if (fs.existsSync(API_KEY_PATH)) fs.unlinkSync(API_KEY_PATH); fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({})); }); From 75ea22c38d9b8d0c6d8b6bdd90335f9a613f7686 Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Thu, 10 Sep 2026 10:53:57 +0200 Subject: [PATCH 07/11] test(api): create shared key after login --- playwright.config.ts | 7 +------ src/api/README.md | 2 +- src/api/api-key.setup.ts | 8 -------- src/auth.spec.ts | 4 +++- 4 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 src/api/api-key.setup.ts diff --git a/playwright.config.ts b/playwright.config.ts index 24845fd..73f5604 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -67,15 +67,10 @@ export default defineConfig({ testMatch: /[\\/]src[\\/]project\.spec\.ts$/, dependencies: ["agent"], }, - { - name: "api-setup", - testMatch: "/src/api/api-key.setup.ts", - dependencies: ["notification", "storage", "project"], - }, { name: "api", testMatch: "/src/api/*.spec.ts", - dependencies: ["api-setup"], + dependencies: ["notification", "storage", "project"], fullyParallel: false, workers: 1, }, diff --git a/src/api/README.md b/src/api/README.md index 4ea1bfb..f6659f5 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -9,7 +9,7 @@ The cleanup project waits for the API tests before revoking the shared UI sessio Run the suite with `pnpm exec playwright test --project=api`. Against an already initialized E2E environment and saved authenticated session, add `--no-deps`. -One API key is created through Account Settings, stored in `src/api-key.json` +One API key is created after login through Account Settings, stored in `src/api-key.json` and reused by every API test. No external account or manual token is required. `contract.spec.ts` compares all 25 operations with `/api/v1/openapi`, checks Swagger diff --git a/src/api/api-key.setup.ts b/src/api/api-key.setup.ts deleted file mode 100644 index 50112fd..0000000 --- a/src/api/api-key.setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {test} from "@playwright/test"; -import {createApiKey} from "./fixtures"; - -test.describe.serial(() => { - test("Create shared API key", async ({browser}) => { - await createApiKey(browser); - }); -}); diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 34872cd..582bfa6 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -1,6 +1,7 @@ import {test, expect} from '@playwright/test'; import {login, register, users} from "./helpers/auth"; import {LOCAL_STORAGE_PATH} from "./helpers/session"; +import {createApiKey} from "./api/fixtures"; const TIMEOUT = undefined // const TIMEOUT = 5000 @@ -93,12 +94,13 @@ test.describe.serial( () => { await expect(toast).toBeVisible() }) - test('Successful login', async ({page}) => { + test('Successful login', async ({page, browser}) => { 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}) + await createApiKey(browser) }) }) From 68944498609daf50f1f31f0cc5e8d5321bf88ced Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Thu, 10 Sep 2026 11:08:59 +0200 Subject: [PATCH 08/11] test(auth): reset password before creating API key --- src/auth.spec.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 582bfa6..17bfb07 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -1,5 +1,5 @@ import {test, expect} from '@playwright/test'; -import {login, register, users} from "./helpers/auth"; +import {login, logout, register, users} from "./helpers/auth"; import {LOCAL_STORAGE_PATH} from "./helpers/session"; import {createApiKey} from "./api/fixtures"; @@ -94,13 +94,38 @@ test.describe.serial( () => { await expect(toast).toBeVisible() }) - test('Successful login', async ({page, browser}) => { + 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}) + }) + + test('Change password and reconnect', async ({page}) => { + const newPassword = 'testPASS654321!' + await page.goto('/dashboard/home') + await page.getByTestId('profile-dropdown').first().click() + await page.getByRole('menuitem', {name: 'Account Settings', exact: true}).click() + await page.getByRole('tab', {name: 'Security & Access', exact: true}).click() + const dialog = page.getByRole('dialog', {name: 'Reset Password', exact: true}) + await page.getByRole('button', {name: 'Reset Password', exact: true}).click() + await page.locator('input[name="currentPassword"]').fill(users["admin"].password) + await page.locator('input[name="newPassword"]').fill(newPassword) + await page.locator('input[name="confirmPassword"]').fill(newPassword) + await page.getByRole('button', {name: 'Submit', exact: true}).click() + await expect(dialog).toBeHidden() + + await page.getByRole('button', {name: 'Close', exact: true}).click() + await logout(page) + await expect(page).toHaveURL(/\/login/) + await login(page, users["admin"].email, newPassword) + await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT}) + await page.context().storageState({path: LOCAL_STORAGE_PATH}) + }) + + test('Create API key', async ({browser}) => { await createApiKey(browser) }) }) From 61b5c48d95f3bed5b7cabcc3ad474455073e357b Mon Sep 17 00:00:00 2001 From: Killian Larcher <98161034+killianlarcher@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:48:55 +0200 Subject: [PATCH 09/11] test(api): share key through session path --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index da35fe5..6aadfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules playwright-report test-results /src/api-key.json +/src/local-storage.json docs From 6663132445ed0c5227ec0f65760ad9308de34a5d Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Thu, 10 Sep 2026 12:20:17 +0200 Subject: [PATCH 10/11] test(api): split resource lifecycle tests --- src/api/agent.spec.ts | 37 +++++---- src/api/contract.spec.ts | 3 + src/api/database.spec.ts | 141 +++++++++++++++++++++-------------- src/api/organisation.spec.ts | 70 ++++++++++------- src/api/project.spec.ts | 43 +++++++---- 5 files changed, 181 insertions(+), 113 deletions(-) diff --git a/src/api/agent.spec.ts b/src/api/agent.spec.ts index cbdadb0..865874b 100644 --- a/src/api/agent.spec.ts +++ b/src/api/agent.spec.ts @@ -2,33 +2,40 @@ import {test, expect, data, error} from "./fixtures"; const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; const apiPath = (path: string) => `/api/v1${path}`; +const name = "API Agent A"; +let agent: {id: string; name: string}; test.describe.serial(() => { - test("Agent API creates, lists, reads, retrieves an edge key and deletes an agent", async ({api}) => { + test("Create agent", async ({api}) => { + agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); + expect(agent).toMatchObject({id: expect.any(String), name}); + }); + + test("List, get and retrieve agent key", async ({api}) => { expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([ expect.objectContaining({name: "Agent A Updated"}), expect.objectContaining({name: "Agent B"}), + expect.objectContaining({id: agent.id, name}), ])); - const name = "API Agent A"; - const agent = await data(await api.post(apiPath("/agents"), {data: {name}}), 201); - expect(agent).toMatchObject({id: expect.any(String), name}); - try { - expect(await data(await api.get(apiPath("/agents")))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id, name})])); - expect(await data(await api.get(apiPath(`/agents/${agent.id}`)))).toMatchObject({id: agent.id, name}); - const key = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); - expect(key.length).toBeGreaterThan(0); - } finally { - const deleted = await api.delete(apiPath(`/agents/${agent.id}`)); - expect(deleted.status()).toBe(204); - expect(await deleted.body()).toHaveLength(0); - } + expect(await data(await api.get(apiPath(`/agents/${agent.id}`)))).toMatchObject({id: agent.id, name}); + const key = await data(await api.get(apiPath(`/agents/${agent.id}/key`))); + expect(key.length).toBeGreaterThan(0); + }); + + test("Delete agent", async ({api}) => { + const deleted = await api.delete(apiPath(`/agents/${agent.id}`)); + expect(deleted.status()).toBe(204); + expect(await deleted.body()).toHaveLength(0); await error(await api.get(apiPath(`/agents/${agent.id}`)), 404); expect(await data(await api.get(apiPath("/agents")))).not.toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id})])); }); - test("Agent API validates payloads and unknown IDs", async ({api}) => { + test("Reject invalid agent payloads", async ({api}) => { await error(await api.post(apiPath("/agents"), {data: {name: ""}}), 422); await error(await api.post(apiPath("/agents"), {data: "{", headers: {"Content-Type": "application/json"}}), 422); + }); + + test("Reject unknown agent IDs", async ({api}) => { for (const suffix of ["", "/key"]) await error(await api.get(apiPath(`/agents/${missingId}${suffix}`)), 404); await error(await api.delete(apiPath(`/agents/${missingId}`)), 404); }); diff --git a/src/api/contract.spec.ts b/src/api/contract.spec.ts index 4518faa..435817c 100644 --- a/src/api/contract.spec.ts +++ b/src/api/contract.spec.ts @@ -28,6 +28,9 @@ test.describe.serial(() => { .filter(method => ["get", "post", "put", "patch", "delete"].includes(method)) .map(method => `${method.toUpperCase()} ${path}`)); expect(actual.sort()).toEqual(endpoints.map(([method, path]) => `${method} ${path}`).sort()); + }); + + test("Swagger documentation is available", async ({request}) => { const docs = await request.get(apiPath("/docs")); expect(docs.status()).toBe(200); expect(await docs.text()).toContain("swagger"); diff --git a/src/api/database.spec.ts b/src/api/database.spec.ts index cc9670b..985d498 100644 --- a/src/api/database.spec.ts +++ b/src/api/database.spec.ts @@ -2,64 +2,93 @@ import {test, expect, data, error} from "./fixtures"; const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; const apiPath = (path: string) => `/api/v1${path}`; +let org: any; +let project: any; +let database: any; +let attachedAgentId: string; +let originalProjectId: string | null; +let originalBackupPolicy: string | null; +let databasePath: string; +let backup: any; +let completed: any; +let restore: any; test.describe.serial(() => { - test("Database API assigns projects, updates schedules, backs up and restores a managed database", async ({api}) => { - test.setTimeout(8 * 60_000); - const org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Database Organization A"}}), 201); - let project: any; - let database: any; - let attachedAgentId: string | undefined; - let originalProjectId: string | null = null; - let originalBackupPolicy: string | null = null; - try { - project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: "API Database Project A"}}), 201); - const databases = await data(await api.get(apiPath("/databases"))); - database = databases.find(item => item.name === "PostgreSQL 18" && item.lastContact && item.agentId); - expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); - attachedAgentId = database.agentId; - originalProjectId = database.projectId ?? null; - originalBackupPolicy = database.backupPolicy ?? null; - await data(await api.post(apiPath(`/organizations/${org.id}/agents`), {data: {agentId: attachedAgentId}}), 201); - const route = apiPath(`/databases/${database.id}`); - await error(await api.patch(route, {data: {projectId: "invalid"}}), 422); - expect(await data(await api.patch(route, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); - expect(await data(await api.patch(route, {data: {projectId: null}}))).toMatchObject({projectId: null}); - await data(await api.patch(route, {data: {projectId: project.id}})); - expect(await data(await api.get(route))).toMatchObject({id: database.id, name: database.name}); - await error(await api.put(`${route}/backup-policy`, {data: {schedule: "invalid"}}), 422); - expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); - expect(await data(await api.put(`${route}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); - expect(await data(await api.get(`${route}/backup`))).toEqual(expect.any(Array)); - await error(await api.get(`${route}/backup/${missingId}`), 404); - await error(await api.post(`${route}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); - await error(await api.post(`${route}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); - const backup = await data(await api.post(`${route}/backup`), 201); - expect(backup).toMatchObject({databaseId: database.id, status: "waiting"}); - let completed: any; - await expect(async () => { - completed = await data(await api.get(`${route}/backup/${backup.id}`)); - expect(completed.status).toBe("success"); - expect(completed.storages).toEqual(expect.arrayContaining([expect.objectContaining({status: "success"})])); - }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); - expect(await data(await api.get(`${route}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); - expect(await data(await api.get(`${route}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); - const storage = completed.storages.find((item: any) => item.status === "success"); - const restore = await data(await api.post(`${route}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); - expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); - await expect(async () => { - expect(await data(await api.get(`${route}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); - }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); - } finally { - if (database) { - const route = apiPath(`/databases/${database.id}`); - await data(await api.put(`${route}/backup-policy`, {data: {schedule: originalBackupPolicy ?? ""}})); - await data(await api.patch(route, {data: {projectId: originalProjectId}})); - } - if (attachedAgentId) await data(await api.delete(apiPath(`/organizations/${org.id}/agents/${attachedAgentId}`))); - if (project) await data(await api.delete(apiPath(`/projects/${project.id}`))); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } + test("Prepare database API resources", async ({api}) => { + org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Database Organization A"}}), 201); + project = await data(await api.post(apiPath(`/organizations/${org.id}/projects`), {data: {name: "API Database Project A"}}), 201); + const databases = await data(await api.get(apiPath("/databases"))); + database = databases.find(item => item.name === "PostgreSQL 18" && item.lastContact && item.agentId); + expect(database, "project dependency exposes an online PostgreSQL database").toBeTruthy(); + attachedAgentId = database.agentId; + originalProjectId = database.projectId ?? null; + originalBackupPolicy = database.backupPolicy ?? null; + databasePath = apiPath(`/databases/${database.id}`); + await data(await api.post(apiPath(`/organizations/${org.id}/agents`), {data: {agentId: attachedAgentId}}), 201); + }); + + test("List and get database", async ({api}) => { + expect(await data(await api.get(apiPath("/databases")))).toEqual( + expect.arrayContaining([expect.objectContaining({id: database.id, name: database.name})]), + ); + expect(await data(await api.get(databasePath))).toMatchObject({id: database.id, name: database.name}); + }); + + test("Assign database project", async ({api}) => { + await error(await api.patch(databasePath, {data: {projectId: "invalid"}}), 422); + expect(await data(await api.patch(databasePath, {data: {projectId: project.id}}))).toMatchObject({projectId: project.id}); + expect(await data(await api.patch(databasePath, {data: {projectId: null}}))).toMatchObject({projectId: null}); + await data(await api.patch(databasePath, {data: {projectId: project.id}})); + }); + + test("Update database backup policy", async ({api}) => { + await error(await api.put(`${databasePath}/backup-policy`, {data: {schedule: "invalid"}}), 422); + expect(await data(await api.put(`${databasePath}/backup-policy`, {data: {schedule: "0 0 1 1 *"}}))).toMatchObject({backupPolicy: "0 0 1 1 *"}); + expect(await data(await api.put(`${databasePath}/backup-policy`, {data: {schedule: ""}}))).toMatchObject({backupPolicy: null}); + }); + + test("List backups and reject invalid restores", async ({api}) => { + expect(await data(await api.get(`${databasePath}/backup`))).toEqual(expect.any(Array)); + await error(await api.get(`${databasePath}/backup/${missingId}`), 404); + await error(await api.post(`${databasePath}/restore`, {data: {backupId: "invalid", backupStorageId: "invalid"}}), 422); + await error(await api.post(`${databasePath}/restore`, {data: {backupId: missingId, backupStorageId: missingId}}), 404); + }); + + test("Create database backup", async ({api}) => { + backup = await data(await api.post(`${databasePath}/backup`), 201); + expect(backup).toMatchObject({databaseId: database.id, status: "waiting"}); + }); + + test("Retrieve database backup and status", async ({api}) => { + test.setTimeout(4 * 60_000); + await expect(async () => { + completed = await data(await api.get(`${databasePath}/backup/${backup.id}`)); + expect(completed.status).toBe("success"); + expect(completed.storages).toEqual(expect.arrayContaining([expect.objectContaining({status: "success"})])); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + expect(await data(await api.get(`${databasePath}/backup`))).toEqual(expect.arrayContaining([expect.objectContaining({id: backup.id, status: "success"})])); + expect(await data(await api.get(`${databasePath}/status`))).toMatchObject({latestBackup: {id: backup.id, status: "success"}}); + }); + + test("Restore database backup", async ({api}) => { + const storage = completed.storages.find((item: any) => item.status === "success"); + restore = await data(await api.post(`${databasePath}/restore`, {data: {backupId: backup.id, backupStorageId: storage.id}}), 201); + expect(restore).toMatchObject({databaseId: database.id, status: "waiting"}); + }); + + test("Retrieve database restoration status", async ({api}) => { + test.setTimeout(4 * 60_000); + await expect(async () => { + expect(await data(await api.get(`${databasePath}/status`))).toMatchObject({latestRestoration: {id: restore.id, status: "success"}}); + }).toPass({timeout: 180_000, intervals: [2_000, 4_000]}); + }); + + test("Delete database API resources", async ({api}) => { + await data(await api.put(`${databasePath}/backup-policy`, {data: {schedule: originalBackupPolicy ?? ""}})); + await data(await api.patch(databasePath, {data: {projectId: originalProjectId}})); + await data(await api.delete(apiPath(`/organizations/${org.id}/agents/${attachedAgentId}`))); + await data(await api.delete(apiPath(`/projects/${project.id}`))); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); }); test("Database API rejects unknown resources", async ({api}) => { diff --git a/src/api/organisation.spec.ts b/src/api/organisation.spec.ts index 2a5d7f8..034e425 100644 --- a/src/api/organisation.spec.ts +++ b/src/api/organisation.spec.ts @@ -2,37 +2,55 @@ import {test, expect, data, error} from "./fixtures"; const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; const apiPath = (path: string) => `/api/v1${path}`; +const name = "API Organization A"; +let org: {id: string; name: string}; +let agent: {id: string}; test.describe.serial(() => { - test("Organization API creates, lists, reads, attaches and detaches an agent, and deletes", async ({api}) => { - expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([ - expect.objectContaining({name: "Organization A"}), - expect.objectContaining({name: "Organization B"}), - ])); - const name = "API Organization A"; - const org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); - let agent: {id: string} | undefined; - try { - expect(org).toMatchObject({id: expect.any(String), name}); - expect(await data(await api.get(apiPath("/organizations")))).toEqual(expect.arrayContaining([expect.objectContaining({id: org.id})])); - expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); - await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); - agent = await data(await api.post(apiPath("/agents"), {data: {name: "API Attached Agent A"}}), 201); - const route = apiPath(`/organizations/${org.id}/agents`); - expect(await data(await api.post(route, {data: {agentId: agent!.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent!.id}); - await error(await api.post(route, {data: {agentId: agent!.id}}), 422); - expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: agent!.id})])); - expect(await data(await api.delete(`${route}/${agent!.id}`))).toEqual({organizationId: org.id, agentId: agent!.id}); - expect(await data(await api.get(route))).toEqual([]); - await error(await api.post(route, {data: {agentId: "invalid"}}), 422); - } finally { - if (agent) expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } + test("Create organization", async ({api}) => { + org = await data(await api.post(apiPath("/organizations"), {data: {name}}), 201); + expect(org).toMatchObject({id: expect.any(String), name}); + await error(await api.post(apiPath("/organizations"), {data: {name}}), 409); + }); + + test("List and get organization", async ({api}) => { + expect(await data(await api.get(apiPath("/organizations")))).toEqual( + expect.arrayContaining([ + expect.objectContaining({name: "Organization A"}), + expect.objectContaining({name: "Organization B"}), + expect.objectContaining({id: org.id, name}), + ]), + ); + expect(await data(await api.get(apiPath(`/organizations/${org.id}`)))).toMatchObject({id: org.id, name}); + }); + + test("Attach agent to organization", async ({api}) => { + agent = await data(await api.post(apiPath("/agents"), {data: {name: "API Attached Agent A"}}), 201); + const route = apiPath(`/organizations/${org.id}/agents`); + expect(await data(await api.post(route, {data: {agentId: agent.id}}), 201)).toMatchObject({organizationId: org.id, agentId: agent.id}); + await error(await api.post(route, {data: {agentId: agent.id}}), 422); + await error(await api.post(route, {data: {agentId: "invalid"}}), 422); + }); + + test("List organization agents", async ({api}) => { + expect(await data(await api.get(apiPath(`/organizations/${org.id}/agents`)))).toEqual( + expect.arrayContaining([expect.objectContaining({id: agent.id})]), + ); + }); + + test("Detach agent from organization", async ({api}) => { + const route = apiPath(`/organizations/${org.id}/agents`); + expect(await data(await api.delete(`${route}/${agent.id}`))).toEqual({organizationId: org.id, agentId: agent.id}); + expect(await data(await api.get(route))).toEqual([]); + expect((await api.delete(apiPath(`/agents/${agent.id}`))).status()).toBe(204); + }); + + test("Delete organization", async ({api}) => { + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); await error(await api.get(apiPath(`/organizations/${org.id}`)), 404); }); - test("Organization API rejects invalid input and unknown resources", async ({api}) => { + test("Reject invalid organization input and unknown resources", async ({api}) => { await error(await api.post(apiPath("/organizations"), {data: {name: ""}}), 422); await error(await api.get(apiPath(`/organizations/${missingId}`)), 404); await error(await api.delete(apiPath(`/organizations/${missingId}`)), 404); diff --git a/src/api/project.spec.ts b/src/api/project.spec.ts index 9a83626..ef872aa 100644 --- a/src/api/project.spec.ts +++ b/src/api/project.spec.ts @@ -2,30 +2,41 @@ import {test, expect, data, error} from "./fixtures"; const missingId = "438e5292-1e7a-49d8-a3c0-3f4c24aceeb0"; const apiPath = (path: string) => `/api/v1${path}`; +let org: {id: string}; +let project: {id: string}; test.describe.serial(() => { - test("Project API creates and lists organization projects, reads and archives a project", async ({api}) => { + test("Create project", async ({api}) => { + org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Project Organization A"}}), 201); + const route = apiPath(`/organizations/${org.id}/projects`); + const name = "API Project A"; + await error(await api.post(route, {data: {name: ""}}), 422); + project = await data(await api.post(route, {data: {name}}), 201); + expect(project).toMatchObject({id: expect.any(String), name}); + await error(await api.post(route, {data: {name}}), 409); + }); + + test("List and get project", async ({api}) => { const organizations = await data(await api.get(apiPath("/organizations"))); const defaultOrganization = organizations.find(organization => organization.name === "Default Organization"); expect(defaultOrganization).toBeTruthy(); expect(await data(await api.get(apiPath(`/organizations/${defaultOrganization.id}/projects`)))).toEqual( expect.arrayContaining([expect.objectContaining({name: "Project A"})]), ); - const org = await data(await api.post(apiPath("/organizations"), {data: {name: "API Project Organization A"}}), 201); - let project: {id: string} | undefined; - try { - const route = apiPath(`/organizations/${org.id}/projects`); - const name = "API Project A"; - await error(await api.post(route, {data: {name: ""}}), 422); - project = await data(await api.post(route, {data: {name}}), 201); - expect(await data(await api.get(route))).toEqual(expect.arrayContaining([expect.objectContaining({id: project!.id, name})])); - expect(await data(await api.get(apiPath(`/projects/${project!.id}`)))).toMatchObject({id: project!.id, organizationId: org.id, name}); - await error(await api.post(route, {data: {name}}), 409); - await error(await api.delete(apiPath(`/organizations/${org.id}`)), 409); - } finally { - if (project) expect(await data(await api.delete(apiPath(`/projects/${project.id}`)))).toMatchObject({isArchived: true}); - expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); - } + expect(await data(await api.get(apiPath(`/organizations/${org.id}/projects`)))).toEqual( + expect.arrayContaining([expect.objectContaining({id: project.id, name: "API Project A"})]), + ); + expect(await data(await api.get(apiPath(`/projects/${project.id}`)))).toMatchObject({ + id: project.id, + organizationId: org.id, + name: "API Project A", + }); + }); + + test("Delete project", async ({api}) => { + await error(await api.delete(apiPath(`/organizations/${org.id}`)), 409); + expect(await data(await api.delete(apiPath(`/projects/${project.id}`)))).toMatchObject({isArchived: true}); + expect(await data(await api.delete(apiPath(`/organizations/${org.id}`)))).toEqual({id: org.id}); }); test("Project API returns not found for unknown IDs", async ({api}) => { From 04ad731843bb0a92c92e05461e4aa61b95dd2a9b Mon Sep 17 00:00:00 2001 From: killian-larcher Date: Sun, 13 Sep 2026 17:37:31 +0200 Subject: [PATCH 11/11] fix: enable backup check on dashboard.spec.ts --- src/dashboard.spec.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/dashboard.spec.ts b/src/dashboard.spec.ts index 688945b..e722491 100644 --- a/src/dashboard.spec.ts +++ b/src/dashboard.spec.ts @@ -30,15 +30,15 @@ test.describe.serial(() => { expect(online![1]).toBe(online![2]); }); - // test("Backup", async ({page}) => { - // await page.goto("/dashboard/home"); - // const online = (await card(page, "Databases").innerText()).match(/(\d+)\/(\d+) online/); - // expect(online).not.toBeNull(); - // await expect(card(page, "Backup")).toContainText("50% available"); - // const backups = (await card(page, "Backup").innerText()).match(/(\d+)\/(\d+)/); - // expect(backups).not.toBeNull(); - // expect(Number(backups![1])).toBe(Number(online![2])); - // expect(Number(backups![2])).toBe(Number(backups![1]) * 2); - // await expect(card(page, "Backup Success Rate").getByText("100.0%", {exact: true})).toBeVisible(); - // }); + test("Backup", async ({page}) => { + await page.goto("/dashboard/home"); + const online = (await card(page, "Databases").innerText()).match(/(\d+)\/(\d+) online/); + expect(online).not.toBeNull(); + await expect(card(page, "Backup")).toContainText("50% available"); + const backups = (await card(page, "Backup").innerText()).match(/(\d+)\/(\d+)/); + expect(backups).not.toBeNull(); + expect(Number(backups![1])).toBe(Number(online![2])); + expect(Number(backups![2])).toBe(Number(backups![1]) * 2); + await expect(card(page, "Backup Success Rate").getByText("100.0%", {exact: true})).toBeVisible(); + }); });