mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: add http smoke tests.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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",
|
||||
|
||||
Generated
+4
-4
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}.`,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user