Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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");
});
});