feat(agiliton-account): Phase 1 service scaffold
TypeScript + Fastify service implementing:
- Google + Microsoft SSO (POST /v1/auth/sso/{google,microsoft})
- JWT issuance + LiteLLM virtual key provisioning on first login
- AES-256-GCM encrypted virtual key storage in Postgres
- Conversation CRUD (GET/POST/DELETE /v1/conversations, /messages)
- GDPR export + soft-delete (/v1/me/export, /v1/me/delete)
- WebSocket MCP bridge (/v1/mcp-bridge) with JWT auth
- MCP demux endpoint (/mcp/demux/:customer_id/mcp) for LiteLLM tool routing
- DB migration script creating sb_customers, sb_conversations, sb_messages
- 9 unit tests (bridge + crypto), all passing
- Dockerfile + docker-compose targeting port 4100
CF-3032
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
src/__tests__/bridge.test.ts
Normal file
117
src/__tests__/bridge.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock config before importing bridge
|
||||
vi.mock("../config.js", () => ({
|
||||
config: {
|
||||
mcpBridgeTimeoutMs: 1000,
|
||||
jwtSecret: "test-secret",
|
||||
encryptionKey: "0".repeat(64),
|
||||
litellmUrl: "http://localhost:4000",
|
||||
litellmMasterKey: "test-key",
|
||||
mcpBridgeSecret: "bridge-secret",
|
||||
defaultBudgetUsd: 30,
|
||||
defaultBudgetDuration: "30d",
|
||||
defaultRpmLimit: 30,
|
||||
defaultModels: ["claude-sonnet-4-6"],
|
||||
googleClientId: "test",
|
||||
googleClientSecret: "test",
|
||||
msClientId: "",
|
||||
msTenantId: "common",
|
||||
dbUrl: "postgres://localhost/test",
|
||||
redisUrl: "redis://localhost:6379",
|
||||
port: 4100,
|
||||
host: "0.0.0.0",
|
||||
jwtExpiresIn: "7d",
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
registerCustomerWs,
|
||||
forwardMcpCall,
|
||||
isCustomerOnline,
|
||||
McpBridgeError,
|
||||
} from "../mcp/bridge.js";
|
||||
|
||||
function makeMockWs() {
|
||||
const listeners: Record<string, Array<(data: unknown) => void>> = {};
|
||||
return {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
on: (event: string, cb: (data: unknown) => void) => {
|
||||
listeners[event] = listeners[event] ?? [];
|
||||
listeners[event].push(cb);
|
||||
},
|
||||
emit: (event: string, data: unknown) => {
|
||||
listeners[event]?.forEach((cb) => cb(data));
|
||||
},
|
||||
_listeners: listeners,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MCP bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Clear sessions between tests by re-importing is complex; use unique IDs
|
||||
});
|
||||
|
||||
it("registers a customer WS and marks them online", () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-1", ws as never);
|
||||
expect(isCustomerOnline("cust-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards a call and resolves on WS response", async () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-2", ws as never);
|
||||
|
||||
const callPromise = forwardMcpCall(
|
||||
"cust-2",
|
||||
{ method: "tools/call", params: { name: "screenshot" }, id: "req-1" },
|
||||
5000
|
||||
);
|
||||
|
||||
// Simulate extension responding
|
||||
ws.emit("message", Buffer.from(JSON.stringify({ id: "req-1", result: { image: "base64data" } })));
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result).toEqual({ image: "base64data" });
|
||||
});
|
||||
|
||||
it("rejects with McpBridgeError when customer is offline", async () => {
|
||||
await expect(
|
||||
forwardMcpCall("offline-cust", { method: "tools/call", id: "req-2" }, 100)
|
||||
).rejects.toThrow(McpBridgeError);
|
||||
});
|
||||
|
||||
it("rejects with McpBridgeError on timeout", async () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-3", ws as never);
|
||||
|
||||
await expect(
|
||||
forwardMcpCall(
|
||||
"cust-3",
|
||||
{ method: "tools/call", id: "req-3" },
|
||||
50 // 50ms timeout
|
||||
)
|
||||
).rejects.toThrow("timeout");
|
||||
});
|
||||
|
||||
it("marks customer offline after WS close", () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-4", ws as never);
|
||||
expect(isCustomerOnline("cust-4")).toBe(true);
|
||||
|
||||
ws.emit("close", undefined);
|
||||
expect(isCustomerOnline("cust-4")).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces stale WS on second registration", () => {
|
||||
const ws1 = makeMockWs();
|
||||
const ws2 = makeMockWs();
|
||||
|
||||
registerCustomerWs("cust-5", ws1 as never);
|
||||
registerCustomerWs("cust-5", ws2 as never);
|
||||
|
||||
expect(ws1.close).toHaveBeenCalled();
|
||||
expect(isCustomerOnline("cust-5")).toBe(true);
|
||||
});
|
||||
});
|
||||
30
src/__tests__/crypto.test.ts
Normal file
30
src/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../config.js", () => ({
|
||||
config: {
|
||||
encryptionKey: "a".repeat(64), // 32-byte hex key
|
||||
},
|
||||
}));
|
||||
|
||||
import { encrypt, decrypt } from "../utils/crypto.js";
|
||||
|
||||
describe("AES-256-GCM encrypt/decrypt", () => {
|
||||
it("round-trips a plaintext string", () => {
|
||||
const original = "sk-litellm-abc123supersecret";
|
||||
const buf = encrypt(original);
|
||||
expect(buf.length).toBeGreaterThan(28);
|
||||
expect(decrypt(buf)).toBe(original);
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for the same input (random IV)", () => {
|
||||
const key = "same-key-test";
|
||||
const buf1 = encrypt(key);
|
||||
const buf2 = encrypt(key);
|
||||
expect(buf1.toString("hex")).not.toBe(buf2.toString("hex"));
|
||||
});
|
||||
|
||||
it("round-trips a unicode string", () => {
|
||||
const original = "sk-🔑-unicode-test-αβγ";
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user