159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import express from "express";
|
|
import { EventEmitter } from "node:events";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockGetExperimental = vi.hoisted(() => vi.fn());
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
addComment: vi.fn(),
|
|
listComments: vi.fn(),
|
|
}));
|
|
const mockSpawn = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
instanceSettingsService: () => ({ getExperimental: mockGetExperimental }),
|
|
issueService: () => mockIssueService,
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
|
|
|
vi.mock("../routes/authz.js", () => ({
|
|
getActorInfo: () => ({ actorId: "user-1", agentId: null, runId: null }),
|
|
assertCompanyAccess: () => {},
|
|
}));
|
|
|
|
async function createApp(deploymentMode: "local_trusted" | "authenticated" = "local_trusted") {
|
|
const { boardChatRoutes } = await import("../routes/board-chat.js");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api", boardChatRoutes({} as any, { deploymentMode }));
|
|
return app;
|
|
}
|
|
|
|
describe("POST /api/board/chat/stream feature flag guard (PAP-137)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns 403 FEATURE_DISABLED when enableConferenceRoomChat is off", async () => {
|
|
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: false });
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post("/api/board/chat/stream")
|
|
.send({ companyId: "company-1", message: "hello" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({
|
|
error: "Conference Room Chat is not enabled",
|
|
code: "FEATURE_DISABLED",
|
|
});
|
|
// The guard must fire before anything is persisted.
|
|
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
|
expect(mockIssueService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 403 DEPLOYMENT_MODE_UNSUPPORTED outside local_trusted even with the flag on", async () => {
|
|
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
|
const app = await createApp("authenticated");
|
|
|
|
const res = await request(app)
|
|
.post("/api/board/chat/stream")
|
|
.send({ companyId: "company-1", message: "hello" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe("DEPLOYMENT_MODE_UNSUPPORTED");
|
|
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets requests past the guard when the flag is on (400 on missing body, not 403)", async () => {
|
|
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
|
const app = await createApp();
|
|
|
|
// Omit the body so the request stops at validation — proves the guard
|
|
// admitted it without spawning the chat subprocess.
|
|
const res = await request(app).post("/api/board/chat/stream").send({});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: "companyId and message are required" });
|
|
});
|
|
});
|
|
|
|
describe("board-chat client disconnect", () => {
|
|
function makeFakeProc() {
|
|
const proc = new EventEmitter() as any;
|
|
proc.stdout = new EventEmitter();
|
|
proc.stderr = new EventEmitter();
|
|
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
|
proc.exitCode = null;
|
|
proc.killed = false;
|
|
proc.kill = vi.fn(() => {
|
|
proc.killed = true;
|
|
});
|
|
return proc;
|
|
}
|
|
|
|
it("kills the spawned subprocess when the client disconnects mid-stream", async () => {
|
|
mockGetExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
|
mockIssueService.list.mockResolvedValue([
|
|
{ id: "issue-1", title: "Board Operations", status: "todo" },
|
|
]);
|
|
mockIssueService.addComment.mockResolvedValue({ id: "comment-1" });
|
|
mockIssueService.listComments.mockResolvedValue([]);
|
|
const fakeProc = makeFakeProc();
|
|
mockSpawn.mockReturnValue(fakeProc);
|
|
const app = await createApp();
|
|
|
|
const req = request(app)
|
|
.post("/api/board/chat/stream")
|
|
.send({ companyId: "company-1", message: "hello" });
|
|
// Start the request without awaiting the (never-ending) SSE response.
|
|
const pending = req.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
expect(fakeProc.kill).not.toHaveBeenCalled();
|
|
|
|
// Client walks away mid-stream.
|
|
req.abort();
|
|
await vi.waitFor(() => expect(fakeProc.kill).toHaveBeenCalledWith("SIGTERM"));
|
|
|
|
// Let the subprocess close handler run so the slot is released.
|
|
fakeProc.exitCode = 143;
|
|
fakeProc.emit("close", 143);
|
|
await pending;
|
|
});
|
|
});
|
|
|
|
describe("board-chat history role classification", () => {
|
|
it("treats only board-concierge comments as assistant turns", async () => {
|
|
const { isConciergeReply } = await import("../routes/board-chat.js");
|
|
|
|
// The relay's own persisted replies.
|
|
expect(
|
|
isConciergeReply({ authorAgentId: null, authorUserId: "board-concierge" }),
|
|
).toBe(true);
|
|
|
|
// A human board user.
|
|
expect(isConciergeReply({ authorAgentId: null, authorUserId: "user-1" })).toBe(
|
|
false,
|
|
);
|
|
|
|
// An agent commenting on the standing issue is NOT this assistant — its
|
|
// words must not be serialized as the assistant's own prior turns.
|
|
expect(
|
|
isConciergeReply({ authorAgentId: "agent-1", authorUserId: null }),
|
|
).toBe(false);
|
|
|
|
// Defensive: an agent comment can never impersonate the concierge even if
|
|
// both author fields are somehow set.
|
|
expect(
|
|
isConciergeReply({ authorAgentId: "agent-1", authorUserId: "board-concierge" }),
|
|
).toBe(false);
|
|
});
|
|
});
|