package sessions import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" "github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/storage" "golang.org/x/crypto/ssh" ) // --------------------------------------------------------------------------- // NewManager creates with empty sessions map // --------------------------------------------------------------------------- func TestNewManager(t *testing.T) { // NewManager requires backing services; pass nil for lightweight tests // where no actual connections are made. mgr := NewManager(nil, nil, nil, nil, nil) if mgr == nil { t.Fatal("NewManager() returned nil") } if mgr.sessions == nil { t.Fatal("expected sessions map to be initialised") } if len(mgr.sessions) != 0 { t.Errorf("expected empty sessions map, got %d entries", len(mgr.sessions)) } } // --------------------------------------------------------------------------- // ListSessions on an empty manager // --------------------------------------------------------------------------- func TestListSessionsEmpty(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) sessions := mgr.ListSessions() if len(sessions) != 0 { t.Errorf("expected 0 sessions, got %d", len(sessions)) } } // --------------------------------------------------------------------------- // createTabName logic // --------------------------------------------------------------------------- func TestCreateTabName(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) tests := []struct { name string profile *models.Profile want string }{ { name: "uses display name when set", profile: &models.Profile{ DisplayName: "Production DB", Host: "db.prod.com", Username: "admin", }, want: "Production DB", }, { name: "falls back to user@host when no display name", profile: &models.Profile{ DisplayName: "", Host: "server.local", Username: "root", }, want: "root@server.local", }, { name: "falls back to host only when no display name or username", profile: &models.Profile{ DisplayName: "", Host: "switch.local", Username: "", }, want: "switch.local", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := mgr.createTabName(tt.profile) if got != tt.want { t.Errorf("createTabName() = %q, want %q", got, tt.want) } }) } } // --------------------------------------------------------------------------- // RenameSession on non-existent session // --------------------------------------------------------------------------- func TestRenameSessionNotFound(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.RenameSession("nonexistent-id", "New Name") if err == nil { t.Error("expected error when renaming non-existent session") } } // --------------------------------------------------------------------------- // PinSession on non-existent session // --------------------------------------------------------------------------- func TestPinSessionNotFound(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.PinSession("nonexistent-id", true) if err == nil { t.Error("expected error when pinning non-existent session") } } // --------------------------------------------------------------------------- // GetSession on non-existent session // --------------------------------------------------------------------------- func TestGetSessionNotFound(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) _, err := mgr.GetSession("nonexistent-id") if err == nil { t.Error("expected error when getting non-existent session") } } // --------------------------------------------------------------------------- // Disconnect on non-existent session // --------------------------------------------------------------------------- func TestDisconnectNotFound(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.Disconnect("nonexistent-id") if err == nil { t.Error("expected error when disconnecting non-existent session") } } // --------------------------------------------------------------------------- // CloseAll on empty manager does not panic // --------------------------------------------------------------------------- func TestCloseAllEmpty(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) // Should not panic. mgr.CloseAll() if len(mgr.ListSessions()) != 0 { t.Error("expected 0 sessions after CloseAll") } } func TestTerminalDimension(t *testing.T) { if got := terminalDimension(0, 80); got != 80 { t.Fatalf("terminalDimension(0, 80) = %d, want 80", got) } if got := terminalDimension(132, 80); got != 132 { t.Fatalf("terminalDimension(132, 80) = %d, want 132", got) } if got := terminalDimension(100000, 80); got != ^uint16(0) { t.Fatalf("terminalDimension(100000, 80) = %d, want %d", got, ^uint16(0)) } } func TestRestartLocalReusesSessionAndAppliesRequestedSize(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("local PTY test requires a Unix shell") } dir := t.TempDir() script := filepath.Join(dir, "local-shell.sh") if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\n"), 0o700); err != nil { t.Fatal(err) } auditStore := &sessionAuditStore{} mgr := NewManager(nil, nil, nil, audit.NewLogger(auditStore), nil) var outputMu sync.Mutex var output strings.Builder disconnected := make(chan string, 2) mgr.SetEventSink(func(name string, payload interface{}) { values, ok := payload.(map[string]interface{}) if !ok { return } if name == "session:data" { encoded, _ := values["data"].(string) decoded, _ := base64.StdEncoding.DecodeString(encoded) outputMu.Lock() _, _ = output.Write(decoded) outputMu.Unlock() } if name == "session:state" && values["state"] == string(models.SessionDisconnected) { disconnected <- values["sessionID"].(string) } }) sessionID, err := mgr.ConnectLocal(script) if err != nil { t.Fatalf("ConnectLocal() error = %v", err) } waitForDisconnectedSession(t, disconnected, sessionID) if err := mgr.RestartLocal(sessionID, 132, 43); err != nil { t.Fatalf("RestartLocal() error = %v", err) } waitForDisconnectedSession(t, disconnected, sessionID) info := mgr.GetSessionInfo(sessionID) if info == nil || info.ID != sessionID || info.State != models.SessionDisconnected { t.Fatalf("session after restart = %#v, want same disconnected session", info) } outputMu.Lock() got := strings.ReplaceAll(output.String(), "\r", "") outputMu.Unlock() if !strings.Contains(got, "43 132\n") { t.Fatalf("local output %q does not contain restarted size 43 132", got) } } func waitForDisconnectedSession(t *testing.T, states <-chan string, want string) { t.Helper() select { case got := <-states: if got != want { t.Fatalf("disconnected session = %q, want %q", got, want) } case <-time.After(5 * time.Second): t.Fatalf("timed out waiting for session %q to disconnect", want) } } type sessionAuditStore struct { mu sync.Mutex entries []*models.AuditEntry } func (s *sessionAuditStore) AddAuditEntry(entry *models.AuditEntry) error { s.mu.Lock() s.entries = append(s.entries, entry) s.mu.Unlock() return nil } func (s *sessionAuditStore) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { s.mu.Lock() defer s.mu.Unlock() return append([]*models.AuditEntry(nil), s.entries...), nil } func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) { store := newSessionTestStore(t) mgr := NewManager(store, nil, nil, nil, nil) callback := mgr.hostKeyCallback("session-1", models.HostKeyAcceptNew) key := testPublicKey(t) if err := callback("jump.example:2222", nil, key); err != nil { t.Fatalf("HostKeyCallback() error = %v", err) } saved, err := store.GetKnownHost("jump.example", 2222) if err != nil { t.Fatalf("GetKnownHost() error = %v", err) } if saved == nil { t.Fatal("expected host key to be saved") } if saved.Host != "jump.example" { t.Fatalf("saved host = %q, want jump.example", saved.Host) } if saved.Port != 2222 { t.Fatalf("saved port = %d, want 2222", saved.Port) } } func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) { mgr := NewManager(newSessionTestStore(t), nil, nil, nil, nil) callback := mgr.hostKeyCallback("session-1", models.HostKeyStrict) err := callback("unknown.example:22", nil, testPublicKey(t)) if err == nil { t.Fatal("expected strict policy to reject unknown host") } } func TestHandleHostKeyResponseRoutesByRequestID(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) ch := make(chan bool, 1) mgr.hostKeyChans = map[string]chan bool{"request-1": ch} mgr.handleHostKeyResponse(map[string]interface{}{ "requestID": "request-1", "accepted": true, }) select { case accepted := <-ch: if !accepted { t.Fatal("accepted = false, want true") } default: t.Fatal("expected host key response to be routed") } } func TestHandleHostKeyResponseIgnoresOtherRequestIDs(t *testing.T) { mgr := NewManager(nil, nil, nil, nil, nil) ch := make(chan bool, 1) mgr.hostKeyChans = map[string]chan bool{"request-1": ch} mgr.handleHostKeyResponse(map[string]interface{}{ "requestID": "request-2", "accepted": true, }) select { case <-ch: t.Fatal("unexpected host key response routed") default: } } func testPublicKey(t *testing.T) ssh.PublicKey { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey() error = %v", err) } key, err := ssh.NewPublicKey(pub) if err != nil { t.Fatalf("NewPublicKey() error = %v", err) } return key } func newSessionTestStore(t *testing.T) *storage.Store { t.Helper() store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("NewStore() error = %v", err) } t.Cleanup(func() { store.Close() }) return store }