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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user