feat(api): add terminal WebSocket gateway with PTY session management (#515)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #515.
This commit is contained in:
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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") })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user