feat: add http smoke tests.

This commit is contained in:
killian-larcher
2026-06-14 17:58:17 +02:00
parent 25689b6e0d
commit f2b7e4bfff
17 changed files with 631 additions and 107 deletions
-8
View File
@@ -48,14 +48,6 @@ seeds/pocket-id/*.db
certificates
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
/e2e/local-storage.json
.claude
.codex
/docs
-57
View File
@@ -3,62 +3,5 @@ CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
up:
@bash $(CLUSTER_SCRIPT)
seed-keycloak:
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml up -d keycloak
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
seed-pocket:
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
seed-auth: seed-keycloak seed-pocket
pocket-token:
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
export-pocket:
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
@mkdir -p ./seeds/pocket-id
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
export-keycloak:
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
@mkdir -p ./seeds/keycloak
@rm -f ./seeds/keycloak/*.json
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
@docker rm -f kc-exporter >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
@docker rm -f kc-exporter >/dev/null 2>&1
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
end-to-end:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
e2e-manual:
@echo "Starting E2E testing..."
@docker compose -f docker-compose.e2e.yml up -d
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
@docker compose -f docker-compose.e2e.yml down --volumes
@echo "Finished E2E testing successfully."
api-test:
pnpm test:api
-37
View File
@@ -1,37 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-e2e-data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
ports:
- "5433:5432"
volumes:
- postgres-e2e-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
volumes:
portabase-e2e-data:
postgres-e2e-data:
-1
View File
@@ -110,7 +110,6 @@
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.8",
"@release-it/bumper": "^7.0.5",
+4 -4
View File
@@ -285,9 +285,6 @@ importers:
'@iconify/react':
specifier: ^6.0.2
version: 6.0.2(react@19.2.7)
'@playwright/test':
specifier: 1.58.2
version: 1.58.2
'@react-email/preview-server':
specifier: 4.3.2
version: 4.3.2(@playwright/test@1.58.2)(postcss@8.5.10)
@@ -10930,6 +10927,7 @@ snapshots:
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
optional: true
'@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
@@ -16896,13 +16894,15 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.58.2: {}
playwright-core@1.58.2:
optional: true
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
optional: true
pluralize@8.0.0: {}
+30
View File
@@ -0,0 +1,30 @@
import {writeContext} from "./helpers/context";
import {ApiHttpClient} from "./helpers/http-client";
import {signInDefaultUser, createStandardApiKey} from "./helpers/auth";
import {discoverAgentId, discoverBackupContext, discoverDatabaseId,} from "./helpers/discovery";
import {env} from "@/env.mjs";
export default async function globalSetup() {
const baseUrl = env.PROJECT_URL;
const client = new ApiHttpClient(baseUrl);
const auth = await signInDefaultUser(client);
const apiKey = await createStandardApiKey(client);
const api = client.withApiKey(apiKey);
const agentId = await discoverAgentId(api);
const databaseId = await discoverDatabaseId(api);
const backup = await discoverBackupContext(api, databaseId);
writeContext({
baseUrl,
authCookie: client.getCookieHeader(),
apiKey,
userEmail: auth.email,
agentId,
databaseId,
backupId: backup.backupId,
backupStorageId: backup.backupStorageId,
});
}
+50
View File
@@ -0,0 +1,50 @@
import {ApiHttpClient} from "./http-client";
import {env} from "@/env.mjs";
export async function signInDefaultUser(client: ApiHttpClient) {
const email = env.AUTH_DEFAULT_USER;
const password = env.AUTH_DEFAULT_PASSWORD;
if (!email || !password) throw new Error("AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD are required");
const result = await client.request("POST", "/api/auth/sign-in/email", {
email,
password,
callbackURL: "/dashboard",
});
if (!result.response.ok) {
throw new Error(`Sign-in failed: ${result.response.status} ${result.text}`);
}
const cookie = client.getCookieHeader();
if (!cookie) {
throw new Error("Sign-in succeeded but no auth cookie was captured");
}
return {
email,
cookie,
};
}
export async function createStandardApiKey(client: ApiHttpClient) {
const result = await client.request("POST", "/api/auth/api-key/create", {
name: "api-test-key",
configId: "standard",
});
if (!result.response.ok) {
throw new Error(
`API key creation failed: ${result.response.status} ${result.text}`,
);
}
const payload = result.json as { key?: string };
if (!payload?.key) {
throw new Error(`API key payload missing key: ${result.text}`);
}
return payload.key;
}
+29
View File
@@ -0,0 +1,29 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export type ApiTestContext = {
baseUrl: string;
authCookie: string;
apiKey: string;
userEmail: string;
agentId: string | null;
databaseId: string | null;
backupId: string | null;
backupStorageId: string | null;
};
export const CONTEXT_PATH = fileURLToPath(
new URL("../.runtime-context.json", import.meta.url),
);
export function writeContext(context: ApiTestContext) {
writeFileSync(CONTEXT_PATH, JSON.stringify(context, null, 2), "utf8");
}
export function readContext(): ApiTestContext {
if (!existsSync(CONTEXT_PATH)) {
throw new Error(`Missing API test context at ${CONTEXT_PATH}`);
}
return JSON.parse(readFileSync(CONTEXT_PATH, "utf8")) as ApiTestContext;
}
+135
View File
@@ -0,0 +1,135 @@
function readIdArray(json: unknown, resourceName: string): Array<{ id: string }> {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error(`Invalid ${resourceName} response: missing data array`);
}
const data = (json as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error(`Invalid ${resourceName} response: data is not an array`);
}
for (const item of data) {
if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") {
throw new Error(
`Invalid ${resourceName} response: each item must include a string id`,
);
}
}
return data as Array<{ id: string }>;
}
function readBackupDetail(json: unknown) {
if (!json || typeof json !== "object" || !("data" in json)) {
throw new Error("Invalid backup detail response: missing data object");
}
const data = (json as { data?: unknown }).data;
if (!data || typeof data !== "object") {
throw new Error("Invalid backup detail response: data is not an object");
}
const id = (data as { id?: unknown }).id;
const storages = (data as { storages?: unknown }).storages;
if (typeof id !== "string") {
throw new Error("Invalid backup detail response: data.id must be a string");
}
if (!Array.isArray(storages)) {
throw new Error(
"Invalid backup detail response: data.storages is not an array",
);
}
for (const item of storages) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { id?: unknown }).id !== "string" ||
typeof (item as { status?: unknown }).status !== "string"
) {
throw new Error(
"Invalid backup detail response: each storage must include string id and status",
);
}
}
return {
id,
storages: storages as Array<{ id: string; status: string }>,
};
}
export async function discoverAgentId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/agents");
if (!result.response.ok) {
throw new Error(`Failed to list agents: ${result.response.status} ${result.text}`);
}
const agents = readIdArray(result.json, "agents");
if (agents.length === 0) {
throw new Error("No agent could be discovered from /api/v1/agents.");
}
return agents[0].id;
}
export async function discoverDatabaseId(api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
}) {
const result = await api.get("/api/v1/databases");
if (!result.response.ok) {
throw new Error(
`Failed to list databases: ${result.response.status} ${result.text}`,
);
}
const databases = readIdArray(result.json, "databases");
if (databases.length === 0) {
throw new Error("No database could be discovered from /api/v1/databases.");
}
return databases[0].id;
}
export async function discoverBackupContext(
api: {
get: (path: string) => Promise<{ response: Response; json: unknown; text: string }>;
},
databaseId: string | null,
) {
if (!databaseId) {
throw new Error("Backup discovery requires a database id.");
}
const backups = await api.get(`/api/v1/databases/${databaseId}/backup`);
if (!backups.response.ok) {
throw new Error(
`Failed to list backups: ${backups.response.status} ${backups.text}`,
);
}
const backupList = readIdArray(backups.json, "backups");
for (const backup of backupList) {
const detail = await api.get(`/api/v1/databases/${databaseId}/backup/${backup.id}`);
if (!detail.response.ok) {
continue;
}
const payload = readBackupDetail(detail.json);
const storage = payload.storages.find((item) => item.status === "success");
if (storage) {
return {
backupId: payload.id,
backupStorageId: storage.id,
};
}
}
throw new Error(
`No successful backup storage could be discovered for database ${databaseId}.`,
);
}
+98
View File
@@ -0,0 +1,98 @@
type JsonValue =
| Record<string, unknown>
| Array<unknown>
| string
| number
| boolean
| null;
function mergeCookieHeaders(
currentCookieHeader: string,
setCookies: string[],
): string {
const cookies = new Map<string, string>();
for (const value of currentCookieHeader.split("; ")) {
if (!value) {
continue;
}
const [name, ...rest] = value.split("=");
cookies.set(name, rest.join("="));
}
for (const value of setCookies) {
const [cookiePair] = value.split(";");
const [name, ...rest] = cookiePair.split("=");
cookies.set(name, rest.join("="));
}
return Array.from(cookies.entries())
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
export class ApiHttpClient {
constructor(
private readonly baseUrl: string,
private cookieHeader = "",
) {}
getCookieHeader() {
return this.cookieHeader;
}
withApiKey(apiKey: string) {
return {
get: (path: string) =>
this.request("GET", path, undefined, { "x-api-key": apiKey }),
post: (path: string, body?: JsonValue) =>
this.request("POST", path, body, { "x-api-key": apiKey }),
patch: (path: string, body?: JsonValue) =>
this.request("PATCH", path, body, { "x-api-key": apiKey }),
delete: (path: string) =>
this.request("DELETE", path, undefined, { "x-api-key": apiKey }),
};
}
async request(
method: string,
path: string,
body?: JsonValue,
headers: HeadersInit = {},
) {
const requestHeaders = new Headers({
origin: new URL(this.baseUrl).origin,
...(body ? { "content-type": "application/json" } : {}),
...(this.cookieHeader ? { cookie: this.cookieHeader } : {}),
});
new Headers(headers).forEach((value, name) => {
requestHeaders.set(name, value);
});
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: requestHeaders,
...(body ? { body: JSON.stringify(body) } : {}),
});
const setCookies = response.headers.getSetCookie?.() ?? [];
if (setCookies.length > 0) {
this.cookieHeader = mergeCookieHeaders(this.cookieHeader, setCookies);
}
const text = await response.text();
let json: unknown = null;
if (text) {
try {
json = JSON.parse(text);
} catch {
json = text;
}
}
return { response, json, text };
}
}
+22
View File
@@ -0,0 +1,22 @@
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
dir: "./",
});
const config = {
displayName: "api",
testEnvironment: "node",
rootDir: "../../",
roots: ["<rootDir>/tests/api"],
testMatch: ["<rootDir>/tests/api/v1/**/*.test.ts"],
globalSetup: "<rootDir>/tests/api/global-setup.ts",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testTimeout: 60_000,
maxWorkers: 1,
verbose: true,
};
export default createJestConfig(config);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["./**/*.ts"]
}
+53
View File
@@ -0,0 +1,53 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
function requireApiKey() {
const context = readContext();
if (!context.apiKey) {
throw new Error(
"Authenticated API tests require AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
return context;
}
describe("api v1 agents", () => {
const context = requireApiKey();
const client = new ApiHttpClient(context.baseUrl);
const api = client.withApiKey(context.apiKey);
it("lists accessible agents", async () => {
const { response, json } = await api.get("/api/v1/agents");
expect(response.status).toBe(200);
const payload = json as { data: Array<{ id: string }> };
expect(Array.isArray(payload.data)).toBe(true);
expect(payload.data.some((agent) => agent.id === context.agentId)).toBe(
true,
);
});
it("returns one agent by id", async () => {
const { response, json } = await api.get(`/api/v1/agents/${context.agentId}`);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.agentId);
});
it("returns an edge key for the discovered agent", async () => {
const { response, json } = await api.get(
`/api/v1/agents/${context.agentId}/key`,
);
expect(response.status).toBe(200);
expect(typeof (json as { data: string }).data).toBe("string");
});
it("returns 404 for an unknown agent id", async () => {
const { response, json } = await api.get(
"/api/v1/agents/11111111-1111-1111-1111-111111111111",
);
expect(response.status).toBe(404);
expect(json).toEqual({ error: "Not found" });
});
});
+25
View File
@@ -0,0 +1,25 @@
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 auth middleware", () => {
const client = new ApiHttpClient(baseUrl);
it("rejects missing api keys", async () => {
const { response, json } = await client.request("GET", "/api/v1/agents");
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Missing API key" });
});
it("rejects invalid api keys", async () => {
const { response, json } = await client.request("GET", "/api/v1/agents", undefined, {
"x-api-key": "sk_invalid",
});
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Invalid or expired API key" });
});
});
+109
View File
@@ -0,0 +1,109 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
function requireDatabaseContext() {
const context = readContext();
if (!context.apiKey) {
throw new Error(
"Authenticated database API tests require AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
if (!context.databaseId) {
throw new Error(
"Database API tests require a discovered database or API_TEST_DATABASE_ID.",
);
}
return context;
}
describe("api v1 databases", () => {
const context = requireDatabaseContext();
const client = new ApiHttpClient(context.baseUrl);
const api = client.withApiKey(context.apiKey);
it("lists accessible databases", async () => {
const { response, json } = await api.get("/api/v1/databases");
expect(response.status).toBe(200);
const payload = json as { data: Array<{ id: string }> };
expect(Array.isArray(payload.data)).toBe(true);
expect(
payload.data.some((database) => database.id === context.databaseId),
).toBe(true);
});
it("returns one database by id", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}`,
);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.databaseId);
});
it("returns database status", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/status`,
);
expect(response.status).toBe(200);
const data = (json as {
data: {
isWaitingForBackup: boolean;
latestBackup: unknown;
latestRestoration: unknown;
};
}).data;
expect(typeof data.isWaitingForBackup).toBe("boolean");
expect(data).toHaveProperty("latestBackup");
expect(data).toHaveProperty("latestRestoration");
});
it("lists backups for the discovered database", async () => {
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/backup`,
);
expect(response.status).toBe(200);
expect(Array.isArray((json as { data: unknown[] }).data)).toBe(true);
});
it("returns one backup with storages when backup context is available", async () => {
if (!context.backupId) {
throw new Error(
"Backup detail coverage requires a discovered backup or API_TEST_BACKUP_ID.",
);
}
const { response, json } = await api.get(
`/api/v1/databases/${context.databaseId}/backup/${context.backupId}`,
);
expect(response.status).toBe(200);
expect((json as { data: { id: string } }).data.id).toBe(context.backupId);
});
it("can trigger restore when backup and storage ids are available", async () => {
if (!context.backupId || !context.backupStorageId) {
throw new Error(
"Restore coverage requires discovered backup and backup storage ids or API_TEST_BACKUP_ID/API_TEST_BACKUP_STORAGE_ID.",
);
}
const { response, json } = await api.post(
`/api/v1/databases/${context.databaseId}/restore`,
{
backupId: context.backupId,
backupStorageId: context.backupStorageId,
},
);
expect([201, 409]).toContain(response.status);
if (response.status === 201) {
expect((json as { data: { status: string } }).data.status).toBe(
"waiting",
);
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { readContext } from "../helpers/context";
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 mcp", () => {
const context = readContext();
const client = new ApiHttpClient(baseUrl);
it("rejects missing api key", async () => {
const { response, json } = await client.request("POST", "/api/v1/mcp", {
jsonrpc: "2.0",
id: "1",
method: "tools/list",
params: {},
});
expect(response.status).toBe(401);
expect(json).toEqual({ error: "Missing API key" });
});
it("passes a valid api key through the middleware", async () => {
if (!context.apiKey) {
throw new Error(
"Authenticated MCP coverage requires AUTH_DEFAULT_USER and AUTH_DEFAULT_PASSWORD.",
);
}
const api = client.withApiKey(context.apiKey);
const { response } = await api.post("/api/v1/mcp", {
jsonrpc: "2.0",
id: "1",
method: "tools/list",
params: {},
});
expect([200, 202, 400]).toContain(response.status);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { ApiHttpClient } from "../helpers/http-client";
const baseUrl = process.env.PROJECT_URL;
if (!baseUrl) {
throw new Error("PROJECT_URL is required");
}
describe("api v1 docs and spec", () => {
const client = new ApiHttpClient(baseUrl);
it("serves the OpenAPI document", async () => {
const { response, json } = await client.request("GET", "/api/v1/openapi");
expect(response.status).toBe(200);
expect((json as { openapi: string }).openapi).toMatch(/^3\./);
});
it("serves the docs page", async () => {
const { response, text } = await client.request("GET", "/api/v1/docs");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(text).toContain("<div id=\"swagger-ui\"></div>");
expect(text).toContain("Portabase API Docs");
expect(text).toContain("/api/v1/openapi");
});
});