Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions assistant/src/__tests__/host-cu-routes-targeted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/**
* Tests for the host-cu-result route 403 guard introduced in Phase 2.
*
* Covers:
* 1. Targeted + correct x-vellum-client-id header → 200 accepted
* 2. Targeted + missing header → 400 BadRequestError
* 3. Targeted + wrong header → 403 ForbiddenError, interaction NOT consumed
* 4. Untargeted (no targetClientId, no header) → 200 accepted (regression)
*
* Resolution goes through conversation.hostCuProxy?.resolve(...). The
* conversation store is mocked to return a controlled conversation object.
*
* Note: host-cu-routes.ts has a deep import chain (conversation-store →
* conversation.ts → ces-client → service-contracts) that requires mocking
* before the module loads. We use dynamic imports to ensure all mocks are
* registered before the route module is evaluated.
*/
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";

// ── Module mocks ─────────────────────────────────────────────────────────────
// Must be registered before the host-cu-routes module is loaded.

mock.module("../config/env.js", () => ({
isHttpAuthDisabled: () => true,
hasUngatedHttpAuthDisabled: () => false,
}));

import type { PendingInteraction } from "../runtime/pending-interactions.js";

const pendingStore = new Map<string, PendingInteraction>();
const resolvedIds: string[] = [];

mock.module("../runtime/pending-interactions.js", () => ({
get: (requestId: string) => pendingStore.get(requestId),
resolve: (requestId: string) => {
const entry = pendingStore.get(requestId);
if (entry) {
pendingStore.delete(requestId);
resolvedIds.push(requestId);
}
return entry;
},
}));

interface CuResolveCall {
requestId: string;
payload: Record<string, unknown>;
}

const cuResolveSpy: CuResolveCall[] = [];

// Controlled conversation map: conversationId → conversation object
const conversationStore = new Map<string, { hostCuProxy?: { resolve: (...args: unknown[]) => void } }>();

mock.module("../daemon/conversation-store.js", () => ({
findConversation: (conversationId: string) => conversationStore.get(conversationId),
}));

// ── Real imports (after mocks) ──────────────────────────────────────────────
// Use dynamic import to ensure the mocks above are applied before loading.

import {
BadRequestError,
ForbiddenError,
} from "../runtime/routes/errors.js";

const { ROUTES } = await import("../runtime/routes/host-cu-routes.js");

afterAll(() => {
mock.restore();
});

const handleHostCuResult = ROUTES.find(
(r: { endpoint: string }) => r.endpoint === "host-cu-result",
)!.handler;

// ── Helpers ──────────────────────────────────────────────────────────────────

function registerPending(
requestId: string,
overrides: Partial<PendingInteraction> = {},
): void {
const entry: PendingInteraction = {
conversationId: "conv-cu-1",
kind: "host_cu",
...overrides,
};
pendingStore.set(requestId, entry);
}

function registerConversation(conversationId = "conv-cu-1"): void {
conversationStore.set(conversationId, {
hostCuProxy: {
resolve(requestId: unknown, payload: unknown) {
cuResolveSpy.push({
requestId: requestId as string,
payload: payload as Record<string, unknown>,
});
},
},
});
}

function cuBody(requestId: string): Record<string, unknown> {
return {
requestId,
axTree: "Button [1]",
executionResult: "Clicked",
};
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("handleHostCuResult — Phase 2 targetClientId guard", () => {
beforeEach(() => {
pendingStore.clear();
conversationStore.clear();
resolvedIds.length = 0;
cuResolveSpy.length = 0;
// Default: register a conversation with a hostCuProxy
registerConversation("conv-cu-1");
});

// ── 1. Targeted + correct header → 200 ────────────────────────────────────

describe("targeted + correct x-vellum-client-id header", () => {
test("returns { accepted: true } and resolves the interaction", async () => {
const requestId = "req-cu-targeted-match";
registerPending(requestId, { targetClientId: "client-A" });

const result = await handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": "client-A" },
});

expect(result).toEqual({ accepted: true });
expect(cuResolveSpy).toHaveLength(1);
expect(cuResolveSpy[0].requestId).toBe(requestId);
expect(resolvedIds).toContain(requestId);
});

test("trims whitespace from header before comparing", async () => {
const requestId = "req-cu-targeted-trim";
registerPending(requestId, { targetClientId: "client-A" });

const result = await handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": " client-A " },
});

expect(result).toEqual({ accepted: true });
});
});

// ── 2. Targeted + missing header → 400 ────────────────────────────────────

describe("targeted + missing x-vellum-client-id header", () => {
test("throws BadRequestError (400) when header is absent", () => {
const requestId = "req-cu-targeted-no-header";
registerPending(requestId, { targetClientId: "client-A" });

expect(() =>
handleHostCuResult({ body: cuBody(requestId) }),
).toThrow(BadRequestError);
});

test("throws BadRequestError (400) when header is empty string", () => {
const requestId = "req-cu-targeted-empty-header";
registerPending(requestId, { targetClientId: "client-A" });

expect(() =>
handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": " " },
}),
).toThrow(BadRequestError);
});

test("interaction is NOT resolved on 400 (still pending)", () => {
const requestId = "req-cu-targeted-no-header-stays";
registerPending(requestId, { targetClientId: "client-A" });

try {
handleHostCuResult({ body: cuBody(requestId) });
} catch {
// expected
}

expect(resolvedIds).not.toContain(requestId);
expect(pendingStore.has(requestId)).toBe(true);
});
});

// ── 3. Targeted + wrong header → 403 ──────────────────────────────────────

describe("targeted + wrong x-vellum-client-id header", () => {
test("throws ForbiddenError (403) when client ID does not match", () => {
const requestId = "req-cu-targeted-mismatch";
registerPending(requestId, { targetClientId: "client-A" });

expect(() =>
handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": "client-B" },
}),
).toThrow(ForbiddenError);
});

test("ForbiddenError message names both submitting and expected client", () => {
const requestId = "req-cu-targeted-mismatch-msg";
registerPending(requestId, { targetClientId: "client-A" });

let caught: unknown;
try {
handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": "client-B" },
});
} catch (e) {
caught = e;
}

expect(caught).toBeInstanceOf(ForbiddenError);
const msg = (caught as ForbiddenError).message;
expect(msg).toContain("client-B");
expect(msg).toContain("client-A");
});

test("interaction is NOT consumed on 403 (pendingInteractions.get still returns it)", () => {
const requestId = "req-cu-targeted-mismatch-stays";
registerPending(requestId, { targetClientId: "client-A" });

try {
handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": "client-B" },
});
} catch {
// expected
}

expect(resolvedIds).not.toContain(requestId);
expect(pendingStore.has(requestId)).toBe(true);
});
});

// ── 4. Untargeted — regression ────────────────────────────────────────────

describe("untargeted request (no targetClientId)", () => {
test("accepts when no header is provided", async () => {
const requestId = "req-cu-untargeted-no-header";
registerPending(requestId);

const result = await handleHostCuResult({
body: cuBody(requestId),
});

expect(result).toEqual({ accepted: true });
expect(cuResolveSpy).toHaveLength(1);
expect(resolvedIds).toContain(requestId);
});

test("accepts when header is present (header ignored for untargeted)", async () => {
const requestId = "req-cu-untargeted-with-header";
registerPending(requestId);

const result = await handleHostCuResult({
body: cuBody(requestId),
headers: { "x-vellum-client-id": "client-whatever" },
});

expect(result).toEqual({ accepted: true });
expect(cuResolveSpy).toHaveLength(1);
});
});
});
48 changes: 47 additions & 1 deletion assistant/src/__tests__/host-file-edit-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "bun:test";

import type { HostFileInput } from "../daemon/host-file-proxy.js";
import type { ToolExecutionResult } from "../tools/types.js";

// Mock HostFileProxy singleton so proxy delegation tests can control it.
let mockFileProxyAvailable = false;
let mockFileProxyRequestFn: (
input: HostFileInput,
conversationId: string,
signal?: AbortSignal,
) => Promise<ToolExecutionResult> = () => Promise.resolve({ content: "", isError: false });

mock.module("../daemon/host-file-proxy.js", () => ({
HostFileProxy: {
get instance() {
return {
isAvailable: () => mockFileProxyAvailable,
request: mockFileProxyRequestFn,
};
},
},
}));

import { hostFileEditTool } from "../tools/host-filesystem/edit.js";
import type { ToolContext } from "../tools/types.js";
Expand All @@ -20,6 +42,8 @@ afterEach(() => {
for (const dir of testDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
mockFileProxyAvailable = false;
mockFileProxyRequestFn = () => Promise.resolve({ content: "", isError: false });
});

describe("host_file_edit tool", () => {
Expand Down Expand Up @@ -268,4 +292,26 @@ describe("host_file_edit tool", () => {
result.content.includes("Successfully edited"),
).toBe(true);
});

test("passes target_client_id to HostFileProxy.instance.request", async () => {
const capturedInputs: HostFileInput[] = [];
mockFileProxyAvailable = true;
mockFileProxyRequestFn = async (input) => {
capturedInputs.push(input);
return { content: "proxied edit", isError: false };
};

await hostFileEditTool.execute(
{
path: "/host/file.txt",
old_string: "old",
new_string: "new",
target_client_id: "client-x",
},
makeContext(),
);

expect(capturedInputs).toHaveLength(1);
expect(capturedInputs[0].targetClientId).toBe("client-x");
});
});
Loading
Loading