diff --git a/backend/internal/service/service_test.go b/backend/internal/service/service_test.go new file mode 100644 index 0000000..47cdd7d --- /dev/null +++ b/backend/internal/service/service_test.go @@ -0,0 +1,216 @@ +package service + +import ( + "context" + "testing" + "time" + + pb "github.com/Ayush-Vish/shellsync/api/proto" + "github.com/Ayush-Vish/shellsync/backend/internal/types" +) + +// MockHub implements types.PtyOutputBroadcaster +type MockHub struct { + broadcasts []types.Message +} + +func (m *MockHub) BroadcastToSession(sessionID string, message types.Message) { + m.broadcasts = append(m.broadcasts, message) +} + +func TestNewShellSyncService(t *testing.T) { + s := NewShellSyncService() + if s == nil { + t.Fatal("NewShellSyncService returned nil") + } + if s.sessions == nil { + t.Error("NewShellSyncService sessions map is nil") + } +} + +func TestCreateSession(t *testing.T) { + s := NewShellSyncService() + ctx := context.Background() + req := &pb.CreateRequest{ + Host: "test-host", + } + + resp, err := s.CreateSession(ctx, req) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + if resp.SessionId == "" { + t.Error("Expected SessionId, got empty string") + } + if len(resp.Password) != 6 { + t.Errorf("Expected 6-digit password, got %s", resp.Password) + } + + // Verify session is stored + session, exists := s.GetSession(resp.SessionId) + if !exists { + t.Error("Session was not stored") + } + if session.Host != "test-host" { + t.Errorf("Expected host test-host, got %s", session.Host) + } + if session.AgentHostname != "test-host" { + t.Errorf("Expected AgentHostname test-host, got %s", session.AgentHostname) + } +} + +func TestGetSession(t *testing.T) { + s := NewShellSyncService() + + // Test non-existent session + _, exists := s.GetSession("non-existent") + if exists { + t.Error("GetSession returned true for non-existent session") + } + + // Create and test existing session manually to avoid dependence on CreateSession + sessionID := "test-session" + session := &types.Session{ + ID: sessionID, + CreatedAt: time.Now(), + } + s.sessions[sessionID] = session + + retrieved, exists := s.GetSession(sessionID) + if !exists { + t.Error("GetSession returned false for existing session") + } + if retrieved != session { + t.Error("GetSession returned wrong session object") + } +} + +func TestAddClientToSession(t *testing.T) { + s := NewShellSyncService() + sessionID := "test-session" + session := &types.Session{ + ID: sessionID, + Clients: make(map[string]*types.Client), + } + s.sessions[sessionID] = session + + clientID := "client-1" + success := s.AddClientToSession(sessionID, clientID) + if !success { + t.Error("AddClientToSession failed for existing session") + } + + session.Mu.RLock() + client, ok := session.Clients[clientID] + session.Mu.RUnlock() + + if !ok { + t.Error("Client was not added to session") + } + if client.ID != clientID { + t.Errorf("Expected client ID %s, got %s", clientID, client.ID) + } + if client.Permission != "read-only" { + t.Errorf("Expected default permission read-only, got %s", client.Permission) + } + + // Test adding to non-existent session + success = s.AddClientToSession("fake-session", "client-2") + if success { + t.Error("AddClientToSession succeeded for non-existent session") + } +} + +func TestRequestNewTerminal(t *testing.T) { + s := NewShellSyncService() + mockHub := &MockHub{} + s.SetHub(mockHub) + + sessionID := "test-session" + session := &types.Session{ + ID: sessionID, + AgentInputChan: make(chan types.AgentCommand, 1), + Terminals: make(map[string]*types.Terminal), + } + s.sessions[sessionID] = session + + frontendID := "frontend-term-1" + + // Test requesting new terminal + s.RequestNewTerminal(sessionID, frontendID, 100, 200) + + // Check if terminal was created in session + session.Mu.Lock() + if len(session.Terminals) != 1 { + t.Errorf("Expected 1 terminal, got %d", len(session.Terminals)) + } + var term *types.Terminal + for _, t := range session.Terminals { + term = t + break + } + session.Mu.Unlock() + + if term.FrontendID != frontendID { + t.Errorf("Expected FrontendID %s, got %s", frontendID, term.FrontendID) + } + if term.Status != "creating" { + t.Errorf("Expected status creating, got %s", term.Status) + } + + // Check if message was broadcasted + if len(mockHub.broadcasts) == 0 { + t.Error("No message broadcasted") + } else { + msg := mockHub.broadcasts[0] + if msg.Type != "terminal_created" { + t.Errorf("Expected message type terminal_created, got %s", msg.Type) + } + } + + // Check if command was sent to agent + select { + case cmd := <-session.AgentInputChan: + createCmd, ok := cmd.(types.CreateTerminalCmd) + if !ok { + t.Error("Expected CreateTerminalCmd in channel") + } + if createCmd.FrontendID != frontendID { + t.Errorf("Expected FrontendID %s in command, got %s", frontendID, createCmd.FrontendID) + } + default: + t.Error("No command sent to agent channel") + } +} + +func TestForwardInputToAgent(t *testing.T) { + s := NewShellSyncService() + sessionID := "test-session" + session := &types.Session{ + ID: sessionID, + AgentInputChan: make(chan types.AgentCommand, 1), + } + s.sessions[sessionID] = session + + terminalID := "term-1" + inputData := []byte("ls\n") + + s.ForwardInputToAgent(sessionID, terminalID, inputData) + + select { + case cmd := <-session.AgentInputChan: + inputCmd, ok := cmd.(types.PtyInputData) + if !ok { + t.Error("Expected PtyInputData in channel") + } + if inputCmd.TerminalID != terminalID { + t.Errorf("Expected TerminalID %s, got %s", terminalID, inputCmd.TerminalID) + } + if string(inputCmd.Data) != string(inputData) { + t.Errorf("Expected data %s, got %s", string(inputData), string(inputCmd.Data)) + } + default: + t.Error("No input command sent to agent channel") + } +} diff --git a/backend/internal/websocket/hub_test.go b/backend/internal/websocket/hub_test.go new file mode 100644 index 0000000..ba07bc6 --- /dev/null +++ b/backend/internal/websocket/hub_test.go @@ -0,0 +1,202 @@ +package websocket + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Ayush-Vish/shellsync/backend/internal/types" + "github.com/gorilla/websocket" +) + +// MockService implements types.PTYService +type MockService struct { + sessions map[string]*types.Session + hub types.PtyOutputBroadcaster +} + +func NewMockService() *MockService { + return &MockService{ + sessions: make(map[string]*types.Session), + } +} + +func (m *MockService) ForwardInputToAgent(sessionID, terminalID string, input []byte) {} +func (m *MockService) RequestNewTerminal(sessionID, frontendID string, x float32, y float32) {} +func (m *MockService) GetSession(sessionID string) (*types.Session, bool) { + s, ok := m.sessions[sessionID] + return s, ok +} +func (m *MockService) GetSessions() []*types.Session { + return nil +} +func (m *MockService) AddClientToSession(sessionID, clientID string) bool { + if s, ok := m.sessions[sessionID]; ok { + if s.Clients == nil { + s.Clients = make(map[string]*types.Client) + } + s.Clients[clientID] = &types.Client{ID: clientID} + return true + } + return false +} +func (m *MockService) SetHub(hub types.PtyOutputBroadcaster) { + m.hub = hub +} + +func TestNewHub(t *testing.T) { + mockService := NewMockService() + hub := NewHub(mockService) + if hub == nil { + t.Fatal("NewHub returned nil") + } + if hub.clients == nil { + t.Error("Hub clients map is nil") + } +} + +func TestHandleWebSocket_NoSessionID(t *testing.T) { + mockService := NewMockService() + hub := NewHub(mockService) + + req := httptest.NewRequest("GET", "/ws", nil) + w := httptest.NewRecorder() + + hub.HandleWebSocket(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestHandleWebSocket_SessionNotFound(t *testing.T) { + mockService := NewMockService() + hub := NewHub(mockService) + + req := httptest.NewRequest("GET", "/ws?session_id=unknown", nil) + w := httptest.NewRecorder() + + hub.HandleWebSocket(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestHandleWebSocket_SuccessAndAuth(t *testing.T) { + mockService := NewMockService() + sessionID := "test-session" + password := "123456" + session := &types.Session{ + ID: sessionID, + Password: password, + AgentHostname: "host-agent", + Clients: make(map[string]*types.Client), + } + mockService.sessions[sessionID] = session + + hub := NewHub(mockService) + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + // Convert http URL to ws URL + u := "ws" + strings.TrimPrefix(server.URL, "http") + "?session_id=" + sessionID + "&client_id=test-client" + + ws, _, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer ws.Close() + + // Prepare auth message + authMsg := map[string]string{ + "type": "authenticate", + "password": password, + "name": "TestUser", + } + if err := ws.WriteJSON(authMsg); err != nil { + t.Fatalf("WriteJSON failed: %v", err) + } + + // Read response + var response types.Message + if err := ws.ReadJSON(&response); err != nil { + t.Fatalf("ReadJSON failed: %v", err) + } + + if response.Type != "auth_success" { + t.Errorf("Expected auth_success, got %s. Error: %s", response.Type, response.Error) + } + if response.Permission != "read-only" { + t.Errorf("Expected read-only permission, got %s", response.Permission) + } +} + +func TestBroadcastToSession(t *testing.T) { + mockService := NewMockService() + sessionID := "test-session" + session := &types.Session{ + ID: sessionID, + Password: "pass", + Clients: make(map[string]*types.Client), + } + mockService.sessions[sessionID] = session + + hub := NewHub(mockService) + server := httptest.NewServer(http.HandlerFunc(hub.HandleWebSocket)) + defer server.Close() + + u := "ws" + strings.TrimPrefix(server.URL, "http") + "?session_id=" + sessionID + "&client_id=client1" + + ws, _, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer ws.Close() + + // Authenticate + authMsg := map[string]string{ + "type": "authenticate", + "password": "pass", + } + ws.WriteJSON(authMsg) + + // Read auth success + var authResp types.Message + ws.ReadJSON(&authResp) + + // Read session state (sent automatically after register) + var stateResp types.Message + ws.ReadJSON(&stateResp) + if stateResp.Type != "session_state" { + // It might be possible we get the broadcast before the state if we are too fast? + // But in registerClient, session state is sent in a goroutine with sleep. + // So likely we receive it. + } + + // Test broadcast + testMsg := types.Message{ + Type: "test_msg", + Content: "hello", + } + + // Wait a bit to ensure client is fully registered + time.Sleep(200 * time.Millisecond) + + hub.BroadcastToSession(sessionID, testMsg) + + // Read broadcasted message + var msg types.Message + if err := ws.ReadJSON(&msg); err != nil { + t.Fatalf("ReadJSON failed: %v", err) + } + + if msg.Type != "test_msg" { + t.Errorf("Expected test_msg, got %s", msg.Type) + } + if msg.Content != "hello" { + t.Errorf("Expected hello, got %s", msg.Content) + } +} diff --git a/client/controller/controller_test.go b/client/controller/controller_test.go new file mode 100644 index 0000000..f0dae1b --- /dev/null +++ b/client/controller/controller_test.go @@ -0,0 +1,48 @@ +package controller + +import ( + "context" + "runtime" + "testing" +) + +func TestNewAgent(t *testing.T) { + agent := NewAgent() + if agent == nil { + t.Fatal("NewAgent returned nil") + } + if agent.ptys == nil { + t.Error("Agent ptys map is nil") + } + if agent.terminalMap == nil { + t.Error("Agent terminalMap is nil") + } +} + +func TestGetDefaultShell(t *testing.T) { + ctx := context.Background() + cmd := getDefaultShell(ctx) + + if cmd == nil { + t.Fatal("getDefaultShell returned nil") + } + + if runtime.GOOS == "windows" { + // Can't easily verify exact shell on all envs, but check path is not empty + if cmd.Path == "" { + t.Error("Command path is empty") + } + } else { + // Unix-like + if cmd.Path == "" { + t.Error("Command path is empty") + } + // Typically /bin/bash or /bin/sh or similar. + // Just ensuring it's not empty is a good start. + } +} + +// NOTE: Testing spawnNewPty and startStream requires mocking grpc streams and pty.Start +// which involves os/exec interactions that are hard to mock without refactoring the code +// to use interfaces for command execution and PTY creation. +// Given the constraints, we focus on available unit tests.