426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
/**
|
|
* Deterministic contract tests for CloudApiClient — real fetch against a
|
|
* loopback HTTP double that returns controlled responses, verifying headers,
|
|
* error handling, auth injection, etc.
|
|
*
|
|
* This is NOT live-cloud coverage. It was formerly misnamed
|
|
* `cloud-api.real.test.ts`, which parked a stub-backed test in the live-API
|
|
* `*.real.test.ts` lane. Live coverage lives in the post-merge real lane (`TEST_LANE=post-merge`).
|
|
*/
|
|
|
|
import * as http from "node:http";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { CloudApiError, InsufficientCreditsError } from "../src/types/cloud";
|
|
import { CloudApiClient } from "../src/utils/cloud-api";
|
|
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
|
|
/** Tracks the last request the server received. */
|
|
let lastRequest: {
|
|
method: string;
|
|
url: string;
|
|
headers: http.IncomingHttpHeaders;
|
|
body: string;
|
|
};
|
|
|
|
/** What the server should respond with next. */
|
|
let nextResponse: {
|
|
status: number;
|
|
contentType: string;
|
|
body: string;
|
|
};
|
|
|
|
function setResponse(status: number, body: Record<string, unknown>): void {
|
|
nextResponse = {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function setTextResponse(status: number, text: string): void {
|
|
nextResponse = { status, contentType: "text/plain", body: text };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
server = http.createServer((req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (c: Buffer) => chunks.push(c));
|
|
req.on("end", () => {
|
|
lastRequest = {
|
|
method: req.method ?? "GET",
|
|
url: req.url ?? "/",
|
|
headers: req.headers,
|
|
body: Buffer.concat(chunks).toString("utf-8"),
|
|
};
|
|
res.writeHead(nextResponse.status, {
|
|
"Content-Type": nextResponse.contentType,
|
|
});
|
|
res.end(nextResponse.body);
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const addr = server.address() as { port: number };
|
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.close();
|
|
});
|
|
|
|
// ─── Constructor & URL handling ──────────────────────────────────────────
|
|
|
|
describe("CloudApiClient construction", () => {
|
|
it("strips trailing slashes from baseUrl", () => {
|
|
const client = new CloudApiClient("https://example.com/api///");
|
|
expect(client.buildWsUrl("/test")).toBe("wss://example.com/api/test");
|
|
});
|
|
|
|
it("stores and retrieves API key", () => {
|
|
const client = new CloudApiClient(baseUrl);
|
|
expect(client.getApiKey()).toBeUndefined();
|
|
client.setApiKey("eliza_test123");
|
|
expect(client.getApiKey()).toBe("eliza_test123");
|
|
});
|
|
|
|
it("builds WebSocket URL by replacing http with ws", () => {
|
|
const client = new CloudApiClient("http://localhost:3000/api/v1");
|
|
expect(client.buildWsUrl("/bridge/abc")).toBe("ws://localhost:3000/api/v1/bridge/abc");
|
|
});
|
|
|
|
it("builds wss URL from https", () => {
|
|
const client = new CloudApiClient("https://cloud.example.com/api/v1");
|
|
expect(client.buildWsUrl("/ws")).toBe("wss://cloud.example.com/api/v1/ws");
|
|
});
|
|
});
|
|
|
|
// ─── GET requests ────────────────────────────────────────────────────────
|
|
|
|
describe("GET requests", () => {
|
|
it("sends correct method and path", async () => {
|
|
setResponse(200, { success: true, data: [1, 2, 3] });
|
|
const client = new CloudApiClient(baseUrl);
|
|
const result = await client.get<{ success: boolean; data: number[] }>("/items");
|
|
expect(lastRequest.method).toBe("GET");
|
|
expect(lastRequest.url).toBe("/items");
|
|
expect(result.data).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("includes Authorization header when API key is set", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl, "eliza_mykey");
|
|
await client.get("/auth-check");
|
|
expect(lastRequest.headers.authorization).toBe("Bearer eliza_mykey");
|
|
});
|
|
|
|
it("omits Authorization header when no API key", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl);
|
|
await client.get("/no-auth");
|
|
expect(lastRequest.headers.authorization).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── POST requests ───────────────────────────────────────────────────────
|
|
|
|
describe("POST requests", () => {
|
|
it("sends JSON body", async () => {
|
|
setResponse(200, { success: true, id: "abc" });
|
|
const client = new CloudApiClient(baseUrl, "eliza_key");
|
|
const result = await client.post<{ id: string }>("/create", {
|
|
name: "test",
|
|
count: 42,
|
|
});
|
|
expect(lastRequest.method).toBe("POST");
|
|
expect(JSON.parse(lastRequest.body)).toEqual({ name: "test", count: 42 });
|
|
expect(result.id).toBe("abc");
|
|
});
|
|
|
|
it("includes auth header for authenticated POST", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl, "eliza_secret");
|
|
await client.post("/endpoint", { x: 1 });
|
|
expect(lastRequest.headers.authorization).toBe("Bearer eliza_secret");
|
|
});
|
|
});
|
|
|
|
// ─── Unauthenticated POST ────────────────────────────────────────────────
|
|
|
|
describe("postUnauthenticated", () => {
|
|
it("does NOT send Authorization header even when API key is set", async () => {
|
|
setResponse(200, { success: true, data: { apiKey: "eliza_new" } });
|
|
const client = new CloudApiClient(baseUrl, "eliza_existing");
|
|
await client.postUnauthenticated("/device-auth", { deviceId: "abc123" });
|
|
expect(lastRequest.headers.authorization).toBeUndefined();
|
|
});
|
|
|
|
it("still sends Content-Type and body", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl);
|
|
await client.postUnauthenticated("/register", { platform: "macos" });
|
|
expect(lastRequest.headers["content-type"]).toBe("application/json");
|
|
expect(JSON.parse(lastRequest.body)).toEqual({ platform: "macos" });
|
|
});
|
|
});
|
|
|
|
// ─── DELETE requests ─────────────────────────────────────────────────────
|
|
|
|
describe("DELETE requests", () => {
|
|
it("sends DELETE method without body", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl, "eliza_key");
|
|
await client.delete("/items/123");
|
|
expect(lastRequest.method).toBe("DELETE");
|
|
expect(lastRequest.url).toBe("/items/123");
|
|
expect(lastRequest.body).toBe("");
|
|
});
|
|
});
|
|
|
|
// ─── Error handling ──────────────────────────────────────────────────────
|
|
|
|
describe("error handling", () => {
|
|
it("throws CloudApiError on 400 JSON response", async () => {
|
|
setResponse(400, {
|
|
success: false,
|
|
error: "Invalid input",
|
|
details: { field: "name" },
|
|
});
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client.get("/bad").catch((e) => e)) as CloudApiError;
|
|
expect(err).toBeInstanceOf(CloudApiError);
|
|
expect(err.statusCode).toBe(400);
|
|
expect(err.message).toBe("Invalid input");
|
|
expect(err.errorBody.details).toEqual({ field: "name" });
|
|
});
|
|
|
|
it("throws CloudApiError on 500 JSON response", async () => {
|
|
setResponse(500, { success: false, error: "Internal server error" });
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client.post("/explode", {}).catch((e) => e)) as CloudApiError;
|
|
expect(err).toBeInstanceOf(CloudApiError);
|
|
expect(err.statusCode).toBe(500);
|
|
});
|
|
|
|
it("throws InsufficientCreditsError on 402 response", async () => {
|
|
setResponse(402, {
|
|
success: false,
|
|
error: "Insufficient balance",
|
|
requiredCredits: 10.5,
|
|
});
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client
|
|
.post("/containers", { name: "x" })
|
|
.catch((e) => e)) as InsufficientCreditsError;
|
|
expect(err).toBeInstanceOf(InsufficientCreditsError);
|
|
expect(err).toBeInstanceOf(CloudApiError);
|
|
expect(err.statusCode).toBe(402);
|
|
expect(err.requiredCredits).toBe(10.5);
|
|
expect(err.message).toBe("Insufficient balance");
|
|
});
|
|
|
|
it("InsufficientCreditsError defaults requiredCredits to 0 when missing", async () => {
|
|
setResponse(402, { success: false, error: "No credits" });
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client.get("/x").catch((e) => e)) as InsufficientCreditsError;
|
|
expect(err.requiredCredits).toBe(0);
|
|
});
|
|
|
|
it("throws CloudApiError on non-JSON error response", async () => {
|
|
setTextResponse(503, "Service Unavailable");
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client.get("/down").catch((e) => e)) as CloudApiError;
|
|
expect(err).toBeInstanceOf(CloudApiError);
|
|
expect(err.statusCode).toBe(503);
|
|
expect(err.message).toContain("503");
|
|
});
|
|
|
|
it("returns {success: true} for non-JSON 200 response", async () => {
|
|
setTextResponse(200, "OK");
|
|
const client = new CloudApiClient(baseUrl);
|
|
const result = await client.get<{ success: boolean }>("/health");
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("throws CloudApiError for 403 quota exceeded", async () => {
|
|
setResponse(403, {
|
|
success: false,
|
|
error: "Quota exceeded",
|
|
quota: { current: 5, max: 5 },
|
|
});
|
|
const client = new CloudApiClient(baseUrl);
|
|
const err = (await client.post("/containers", {}).catch((e) => e)) as CloudApiError;
|
|
expect(err.statusCode).toBe(403);
|
|
expect(err.errorBody.quota).toEqual({ current: 5, max: 5 });
|
|
});
|
|
});
|
|
|
|
// ─── setBaseUrl / setApiKey mid-flight ───────────────────────────────────
|
|
|
|
describe("dynamic reconfiguration", () => {
|
|
it("setBaseUrl changes target for subsequent requests", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient("http://wrong-host:9999");
|
|
client.setBaseUrl(baseUrl);
|
|
await client.get("/works");
|
|
expect(lastRequest.url).toBe("/works");
|
|
});
|
|
|
|
it("setApiKey changes auth for subsequent requests", async () => {
|
|
setResponse(200, { success: true });
|
|
const client = new CloudApiClient(baseUrl, "old_key");
|
|
client.setApiKey("new_key");
|
|
await client.get("/check");
|
|
expect(lastRequest.headers.authorization).toBe("Bearer new_key");
|
|
});
|
|
});
|