277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createCloudApiServer } from "./server.ts";
|
|
import { codexStdioChatFixture, codexStdioReadyFixture } from "./server-test-helpers.ts";
|
|
|
|
test("cloud api accepts user-billing API keys and records Code Agent billing usage", async () => {
|
|
const calls = [];
|
|
const userBillingClient = {
|
|
configured: true,
|
|
async introspect(token) {
|
|
calls.push({ op: "introspect", tokenPrefix: token.slice(0, 8) });
|
|
assert.equal(token, "hwl_user_billing_test_secret");
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
active: true,
|
|
principal: {
|
|
userId: "usr_user_billing_agent",
|
|
email: "agent@hwlab.local",
|
|
username: "billing-agent",
|
|
role: "user",
|
|
scopes: ["api"],
|
|
authType: "api-key",
|
|
keyId: "key_user_billing_agent"
|
|
}
|
|
}
|
|
};
|
|
},
|
|
async billingPreflight(body) {
|
|
calls.push({ op: "preflight", body });
|
|
assert.equal(body.apiKey, "hwl_user_billing_test_secret");
|
|
assert.equal(body.serviceId, "hwlab-code-agent");
|
|
assert.equal(body.estimatedCredits, 1);
|
|
assert.equal(body.idempotencyKey, "code-agent:trc_user_billing_agent:preflight");
|
|
assert.equal(body.metadata.resource, "code-agent-turn");
|
|
return { ok: true, status: 200, body: { allowed: true, reservationId: "res_user_billing_agent", estimatedCredits: 1, expiresAt: "2026-06-13T12:00:00.000Z" } };
|
|
},
|
|
async billingRecord(body) {
|
|
calls.push({ op: "record", body });
|
|
assert.equal(body.reservationId, "res_user_billing_agent");
|
|
assert.equal(body.serviceId, "hwlab-code-agent");
|
|
assert.equal(body.usedCredits, 1);
|
|
assert.equal(body.idempotencyKey, "code-agent:trc_user_billing_agent:record");
|
|
return { ok: true, status: 200, body: { recordId: "use_user_billing_agent", credits: 1, balance: 99 } };
|
|
},
|
|
async billingSummary(token, options = {}) {
|
|
calls.push({ op: "summary", tokenPrefix: token.slice(0, 8), options });
|
|
assert.equal(token, "hwl_user_billing_test_secret");
|
|
assert.equal(options.limit, 20);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
contractVersion: "user-billing-summary-v1",
|
|
status: "ok",
|
|
credits: { balance: 99, reserved: 0, available: 99 },
|
|
usage: { totalCredits: 1, totalQuantity: 1200, recordCount: 1, byService: [{ serviceId: "hwlab-code-agent", credits: 1, quantity: 1200, recordCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" }] },
|
|
ledger: { count: 1, limit: 20, rows: [{ id: "led_user_billing_agent", kind: "usage", reason: "hwlab-code-agent", deltaCredits: -1, balanceAfter: 99, createdAt: "2026-06-13T12:00:00.000Z" }] },
|
|
reservations: { activeCount: 0, active: [] },
|
|
state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } },
|
|
valuesRedacted: true
|
|
}
|
|
};
|
|
},
|
|
async adminBillingSummary(options = {}) {
|
|
calls.push({ op: "adminSummary", options });
|
|
assert.equal(options.limit, 50);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
contractVersion: "user-billing-admin-summary-v1",
|
|
status: "ok",
|
|
count: 1,
|
|
limit: 50,
|
|
totals: { users: 1, activeUsers: 1, balanceCredits: 99, usageCredits: 1, activeReservations: 0 },
|
|
users: [{
|
|
user: { id: "usr_user_billing_agent", email: "agent@hwlab.local", username: "billing-agent", displayName: "Billing Agent", role: "user", status: "active" },
|
|
planId: "default",
|
|
credits: { balance: 99, reserved: 0, available: 99 },
|
|
usage: { totalCredits: 1, totalQuantity: 1200, recordCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" },
|
|
apiKeys: { activeCount: 1, totalCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" },
|
|
reservations: { activeCount: 0, active: [] }
|
|
}],
|
|
state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } },
|
|
valuesRedacted: true
|
|
}
|
|
};
|
|
}
|
|
};
|
|
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-user-billing-agent-"));
|
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-user-billing-agent-codex-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
userBillingClient,
|
|
codexStdioManager: {
|
|
describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
|
|
async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
|
|
async chat(params = {}) {
|
|
return {
|
|
...codexStdioChatFixture({ workspace, codexHome, params }),
|
|
usage: { totalTokens: 1200 },
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const authHeader = { authorization: "Bearer hwl_user_billing_test_secret" };
|
|
const me = await fetch(`http://127.0.0.1:${port}/v1/users/me`, { headers: authHeader });
|
|
assert.equal(me.status, 200);
|
|
const meBody = await me.json();
|
|
assert.equal(meBody.actor.id, "usr_user_billing_agent");
|
|
assert.equal(meBody.authMethod, "user-billing-api-key");
|
|
|
|
const sessionResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...authHeader
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_user_billing_agent",
|
|
sessionId: "ses_user_billing_agent",
|
|
providerProfile: "deepseek"
|
|
})
|
|
});
|
|
assert.equal(sessionResponse.status, 201);
|
|
const manual = await sessionResponse.json();
|
|
assert.equal(manual.ok, true);
|
|
assert.equal(manual.session.sessionId, "ses_user_billing_agent");
|
|
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer hwl_user_billing_test_secret",
|
|
"x-trace-id": "trc_user_billing_agent",
|
|
prefer: "respond-async"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: manual.session.conversationId,
|
|
sessionId: manual.session.sessionId,
|
|
shortConnection: true,
|
|
message: "billing integration smoke"
|
|
})
|
|
});
|
|
assert.equal(submit.status, 202);
|
|
const result = await pollUserBillingAgentResult(port, "trc_user_billing_agent", authHeader);
|
|
assert.equal(result.status, "completed");
|
|
assert.equal(result.billing.recorded, true);
|
|
assert.equal(result.billing.reservationId, "res_user_billing_agent");
|
|
assert.equal(result.billing.recordId, "use_user_billing_agent");
|
|
const summaryResponse = await fetch(`http://127.0.0.1:${port}/v1/usage/summary`, { headers: authHeader });
|
|
assert.equal(summaryResponse.status, 200);
|
|
const summary = await summaryResponse.json();
|
|
assert.equal(summary.contractVersion, "user-billing-summary-v1");
|
|
assert.equal(summary.credits.available, 99);
|
|
assert.equal(summary.usage.byService[0].serviceId, "hwlab-code-agent");
|
|
assert.equal(summary.state.stateAuthority, "pk01-postgres");
|
|
assert.equal(summary.state.redis.role, "cache-only");
|
|
assert.equal(summary.proxy.source, "hwlab-user-billing");
|
|
assert.equal(JSON.stringify(summary).includes("hwl_user_billing_test_secret"), false);
|
|
|
|
const adminDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: authHeader });
|
|
assert.equal(adminDenied.status, 403);
|
|
|
|
assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary", "introspect"]);
|
|
assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(codexHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function pollUserBillingAgentResult(port, traceId, headers) {
|
|
let last = null;
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${encodeURIComponent(traceId)}`, { headers });
|
|
last = {
|
|
status: response.status,
|
|
body: await response.json()
|
|
};
|
|
if (response.status === 200) return last.body;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`);
|
|
}
|
|
|
|
test("cloud api proxies admin billing summary for web admins without exposing user-billing secrets", async () => {
|
|
const calls = [];
|
|
const userBillingClient = {
|
|
configured: true,
|
|
async adminBillingSummary(options = {}) {
|
|
calls.push({ op: "adminSummary", options });
|
|
assert.equal(options.limit, 50);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
contractVersion: "user-billing-admin-summary-v1",
|
|
status: "ok",
|
|
count: 1,
|
|
limit: 50,
|
|
totals: { users: 1, activeUsers: 1, balanceCredits: 25, reservedCredits: 5, availableCredits: 20, usageCredits: 3, activeReservations: 1 },
|
|
users: [{
|
|
user: { id: "usr_billing_admin_subject", email: "subject@hwlab.local", username: "billing-subject", displayName: "Billing Subject", role: "user", status: "active" },
|
|
planId: "default",
|
|
credits: { balance: 25, reserved: 5, available: 20 },
|
|
usage: { totalCredits: 3, totalQuantity: 2048, recordCount: 2, lastUsedAt: "2026-06-13T13:00:00.000Z" },
|
|
apiKeys: { activeCount: 1, totalCount: 2, lastUsedAt: "2026-06-13T12:30:00.000Z" },
|
|
reservations: { activeCount: 1, active: [{ reservationId: "res_admin_billing", serviceId: "hwlab-hwpod", estimatedCredits: 5, estimatedTokens: 0, status: "reserved", expiresAt: "2026-06-13T13:30:00.000Z", createdAt: "2026-06-13T13:00:00.000Z" }] }
|
|
}],
|
|
state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } },
|
|
valuesRedacted: true
|
|
}
|
|
};
|
|
}
|
|
};
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127"
|
|
},
|
|
userBillingClient
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const { port } = server.address();
|
|
const login = await fetch(`http://127.0.0.1:${port}/auth/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: "admin", password: "admin-password-1127" })
|
|
});
|
|
assert.equal(login.status, 200);
|
|
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
|
|
assert.match(cookie, /^hwlab_session=/u);
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: { cookie } });
|
|
assert.equal(response.status, 200);
|
|
const summary = await response.json();
|
|
assert.equal(summary.contractVersion, "user-billing-admin-summary-v1");
|
|
assert.equal(summary.users[0].user.id, "usr_billing_admin_subject");
|
|
assert.equal(summary.users[0].credits.available, 20);
|
|
assert.equal(summary.users[0].reservations.active[0].serviceId, "hwlab-hwpod");
|
|
assert.equal(summary.state.stateAuthority, "pk01-postgres");
|
|
assert.equal(summary.state.redis.role, "cache-only");
|
|
assert.equal(summary.proxy.source, "hwlab-user-billing");
|
|
assert.equal(JSON.stringify(summary).includes("admin-password-1127"), false);
|
|
assert.deepEqual(calls.map((call) => call.op), ["adminSummary"]);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|