Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
/**
|
|
* TerminalGateway Tests
|
|
*
|
|
* Unit tests for WebSocket terminal gateway:
|
|
* - Authentication on connection
|
|
* - terminal:create event handling
|
|
* - terminal:input event handling
|
|
* - terminal:resize event handling
|
|
* - terminal:close event handling
|
|
* - disconnect cleanup
|
|
* - Error paths
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
import type { Socket } from "socket.io";
|
|
import { TerminalGateway } from "./terminal.gateway";
|
|
import { TerminalService } from "./terminal.service";
|
|
import { AuthService } from "../auth/auth.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
|
|
// ==========================================
|
|
// Mocks
|
|
// ==========================================
|
|
|
|
// Mock node-pty globally so TerminalService doesn't fail to import
|
|
vi.mock("node-pty", () => ({
|
|
spawn: vi.fn(() => ({
|
|
onData: vi.fn(),
|
|
onExit: vi.fn(),
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
kill: vi.fn(),
|
|
pid: 1000,
|
|
})),
|
|
}));
|
|
|
|
interface AuthenticatedSocket extends Socket {
|
|
data: {
|
|
userId?: string;
|
|
workspaceId?: string;
|
|
};
|
|
}
|
|
|
|
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
|
return {
|
|
id,
|
|
emit: vi.fn(),
|
|
join: vi.fn(),
|
|
leave: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
data: {},
|
|
handshake: {
|
|
auth: { token: "valid-token" },
|
|
query: {},
|
|
headers: {},
|
|
},
|
|
} as unknown as AuthenticatedSocket;
|
|
}
|
|
|
|
function createMockAuthService() {
|
|
return {
|
|
verifySession: vi.fn().mockResolvedValue({
|
|
user: { id: "user-123" },
|
|
session: { id: "session-123" },
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createMockPrismaService() {
|
|
return {
|
|
workspaceMember: {
|
|
findFirst: vi.fn().mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMockTerminalService() {
|
|
return {
|
|
createSession: vi.fn().mockReturnValue({
|
|
sessionId: "session-uuid-1",
|
|
name: undefined,
|
|
cols: 80,
|
|
rows: 24,
|
|
}),
|
|
writeToSession: vi.fn(),
|
|
resizeSession: vi.fn(),
|
|
closeSession: vi.fn().mockReturnValue(true),
|
|
closeWorkspaceSessions: vi.fn(),
|
|
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
|
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// Tests
|
|
// ==========================================
|
|
|
|
describe("TerminalGateway", () => {
|
|
let gateway: TerminalGateway;
|
|
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
|
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
|
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
|
let mockClient: AuthenticatedSocket;
|
|
|
|
beforeEach(() => {
|
|
mockAuthService = createMockAuthService();
|
|
mockPrismaService = createMockPrismaService();
|
|
mockTerminalService = createMockTerminalService();
|
|
mockClient = createMockSocket();
|
|
|
|
gateway = new TerminalGateway(
|
|
mockAuthService as unknown as AuthService,
|
|
mockPrismaService as unknown as PrismaService,
|
|
mockTerminalService as unknown as TerminalService
|
|
);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ==========================================
|
|
// handleConnection (authentication)
|
|
// ==========================================
|
|
describe("handleConnection", () => {
|
|
it("should authenticate client and join workspace room on valid token", async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({
|
|
user: { id: "user-123" },
|
|
});
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
|
|
await gateway.handleConnection(mockClient);
|
|
|
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
|
expect(mockClient.data.userId).toBe("user-123");
|
|
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
|
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
|
});
|
|
|
|
it("should disconnect and emit error if no token provided", async () => {
|
|
const clientNoToken = createMockSocket("no-token");
|
|
clientNoToken.handshake = {
|
|
auth: {},
|
|
query: {},
|
|
headers: {},
|
|
} as typeof clientNoToken.handshake;
|
|
|
|
await gateway.handleConnection(clientNoToken);
|
|
|
|
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
|
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("no token") })
|
|
);
|
|
});
|
|
|
|
it("should disconnect and emit error if token is invalid", async () => {
|
|
mockAuthService.verifySession.mockResolvedValue(null);
|
|
|
|
await gateway.handleConnection(mockClient);
|
|
|
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
|
);
|
|
});
|
|
|
|
it("should disconnect and emit error if no workspace access", async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
|
|
|
await gateway.handleConnection(mockClient);
|
|
|
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
|
);
|
|
});
|
|
|
|
it("should disconnect and emit error if auth throws", async () => {
|
|
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
|
|
|
await gateway.handleConnection(mockClient);
|
|
|
|
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.any(String) })
|
|
);
|
|
});
|
|
|
|
it("should extract token from handshake.query as fallback", async () => {
|
|
const clientQueryToken = createMockSocket("query-token-client");
|
|
clientQueryToken.handshake = {
|
|
auth: {},
|
|
query: { token: "query-token" },
|
|
headers: {},
|
|
} as typeof clientQueryToken.handshake;
|
|
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
|
|
await gateway.handleConnection(clientQueryToken);
|
|
|
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
|
});
|
|
|
|
it("should extract token from Authorization header as last fallback", async () => {
|
|
const clientHeaderToken = createMockSocket("header-token-client");
|
|
clientHeaderToken.handshake = {
|
|
auth: {},
|
|
query: {},
|
|
headers: { authorization: "Bearer header-token" },
|
|
} as typeof clientHeaderToken.handshake;
|
|
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
|
|
await gateway.handleConnection(clientHeaderToken);
|
|
|
|
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// handleDisconnect
|
|
// ==========================================
|
|
describe("handleDisconnect", () => {
|
|
it("should close all workspace sessions on disconnect", async () => {
|
|
await gateway.handleConnection(mockClient);
|
|
vi.clearAllMocks();
|
|
|
|
gateway.handleDisconnect(mockClient);
|
|
|
|
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
|
});
|
|
|
|
it("should not throw for unauthenticated client disconnect", () => {
|
|
const unauthClient = createMockSocket("unauth-disconnect");
|
|
|
|
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
|
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// handleCreate (terminal:create)
|
|
// ==========================================
|
|
describe("handleCreate", () => {
|
|
beforeEach(async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
await gateway.handleConnection(mockClient);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should create a PTY session and emit terminal:created", async () => {
|
|
mockTerminalService.createSession.mockReturnValue({
|
|
sessionId: "new-session-id",
|
|
cols: 80,
|
|
rows: 24,
|
|
});
|
|
|
|
await gateway.handleCreate(mockClient, {});
|
|
|
|
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:created",
|
|
expect.objectContaining({ sessionId: "new-session-id" })
|
|
);
|
|
});
|
|
|
|
it("should pass cols, rows, cwd, name to service", async () => {
|
|
await gateway.handleCreate(mockClient, {
|
|
cols: 132,
|
|
rows: 50,
|
|
cwd: "/home/user",
|
|
name: "my-shell",
|
|
});
|
|
|
|
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error if not authenticated", async () => {
|
|
const unauthClient = createMockSocket("unauth");
|
|
|
|
await gateway.handleCreate(unauthClient, {});
|
|
|
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error if service throws (session limit)", async () => {
|
|
mockTerminalService.createSession.mockImplementation(() => {
|
|
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
|
});
|
|
|
|
await gateway.handleCreate(mockClient, {});
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
|
await gateway.handleCreate(mockClient, { cols: -1 });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// handleInput (terminal:input)
|
|
// ==========================================
|
|
describe("handleInput", () => {
|
|
beforeEach(async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
await gateway.handleConnection(mockClient);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should write data to the PTY session", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
|
|
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
|
|
|
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
|
});
|
|
|
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
|
|
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
);
|
|
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should emit terminal:error if not authenticated", async () => {
|
|
const unauthClient = createMockSocket("unauth");
|
|
|
|
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
|
|
|
expect(unauthClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
|
await gateway.handleInput(mockClient, { data: "some input" });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// handleResize (terminal:resize)
|
|
// ==========================================
|
|
describe("handleResize", () => {
|
|
beforeEach(async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
await gateway.handleConnection(mockClient);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should resize the PTY session", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
|
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
|
|
|
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
|
});
|
|
|
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
|
|
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
|
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// handleClose (terminal:close)
|
|
// ==========================================
|
|
describe("handleClose", () => {
|
|
beforeEach(async () => {
|
|
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-123",
|
|
workspaceId: "workspace-456",
|
|
role: "MEMBER",
|
|
});
|
|
await gateway.handleConnection(mockClient);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should close an existing PTY session", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
mockTerminalService.closeSession.mockReturnValue(true);
|
|
|
|
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
|
|
|
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
|
});
|
|
|
|
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
|
|
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
|
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
mockTerminalService.closeSession.mockReturnValue(false);
|
|
|
|
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
);
|
|
});
|
|
|
|
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
|
await gateway.handleClose(mockClient, {});
|
|
|
|
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
"terminal:error",
|
|
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
);
|
|
});
|
|
});
|
|
});
|