/** * 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; let mockPrismaService: ReturnType; let mockTerminalService: ReturnType; 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") }) ); }); }); });