diff --git a/muxterm/pool_test.go b/muxterm/pool_test.go index 6c5531f..225f6bd 100644 --- a/muxterm/pool_test.go +++ b/muxterm/pool_test.go @@ -13,7 +13,7 @@ func TestNewPool(t *testing.T) { } } -func TestGetEmpty(t *testing.T) { +func TestPool_Get_NoSession(t *testing.T) { p := NewPool() s := p.Get("nonexistent") if s != nil { @@ -21,35 +21,37 @@ func TestGetEmpty(t *testing.T) { } } -func TestIsAliveEmpty(t *testing.T) { +func TestPool_IsAlive_NoSession(t *testing.T) { p := NewPool() if p.IsAlive("nonexistent") { t.Fatal("IsAlive on empty pool should return false") } } -// setupTmuxSession creates a tmux session for testing. Returns a cleanup function. -func setupTmuxSession(t *testing.T, name string) func() { - t.Helper() - // Kill any leftover session with this name +func TestPool_CloseAll_Empty(t *testing.T) { + p := NewPool() + p.CloseAll() // must not panic +} + +func TestPool_Attach_Real(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + name := "test-muxterm" + + // Ensure the tmux session exists. exec.Command("tmux", "kill-session", "-t", name).Run() cmd := exec.Command("tmux", "new-session", "-d", "-s", name, "-x", "80", "-y", "24") if err := cmd.Run(); err != nil { t.Fatalf("failed to create tmux session %q: %v", name, err) } - return func() { - exec.Command("tmux", "kill-session", "-t", name).Run() - } -} - -func TestAttachCreatesSession(t *testing.T) { - name := "test-pool-attach" - cleanup := setupTmuxSession(t, name) - defer cleanup() + defer exec.Command("tmux", "kill-session", "-t", name).Run() p := NewPool() defer p.CloseAll() + // Attach sess, err := p.Attach(name, 80, 24) if err != nil { t.Fatalf("Attach failed: %v", err) @@ -60,165 +62,29 @@ func TestAttachCreatesSession(t *testing.T) { if sess.PTY == nil { t.Fatal("Session PTY is nil") } - if sess.Cmd == nil { - t.Fatal("Session Cmd is nil") - } - if sess.Cols != 80 { - t.Errorf("Cols = %d, want 80", sess.Cols) - } - if sess.Rows != 24 { - t.Errorf("Rows = %d, want 24", sess.Rows) - } -} - -func TestGetReturnsSession(t *testing.T) { - name := "test-pool-get" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - defer p.CloseAll() - - _, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Attach failed: %v", err) - } - - sess := p.Get(name) - if sess == nil { - t.Fatal("Get returned nil after Attach") - } -} - -func TestIsAliveAfterAttach(t *testing.T) { - name := "test-pool-alive" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - defer p.CloseAll() - - _, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Attach failed: %v", err) - } + // IsAlive if !p.IsAlive(name) { t.Fatal("IsAlive should return true after Attach") } -} - -func TestAttachReusesLiveSession(t *testing.T) { - name := "test-pool-reuse" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - defer p.CloseAll() - - sess1, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("First Attach failed: %v", err) - } - - sess2, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Second Attach failed: %v", err) - } - - if sess1 != sess2 { - t.Fatal("Attach should reuse existing live session") - } -} - -func TestResize(t *testing.T) { - name := "test-pool-resize" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - defer p.CloseAll() - - _, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Attach failed: %v", err) - } + // Resize err = p.Resize(name, 120, 40) if err != nil { t.Fatalf("Resize failed: %v", err) } - - sess := p.Get(name) - if sess.Cols != 120 { - t.Errorf("After resize Cols = %d, want 120", sess.Cols) + resized := p.Get(name) + if resized.Cols != 120 { + t.Errorf("After resize Cols = %d, want 120", resized.Cols) } - if sess.Rows != 40 { - t.Errorf("After resize Rows = %d, want 40", sess.Rows) - } -} - -func TestResizeNonexistent(t *testing.T) { - p := NewPool() - err := p.Resize("nonexistent", 80, 24) - if err == nil { - t.Fatal("Resize on nonexistent session should return error") - } -} - -func TestCloseAll(t *testing.T) { - name := "test-pool-closeall" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - - _, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Attach failed: %v", err) + if resized.Rows != 40 { + t.Errorf("After resize Rows = %d, want 40", resized.Rows) } + // Cleanup p.CloseAll() - - // Give the background goroutine time to clean up time.Sleep(100 * time.Millisecond) - if p.IsAlive(name) { t.Fatal("IsAlive should return false after CloseAll") } } - -func TestAttachCleansUpDeadSession(t *testing.T) { - name := "test-pool-dead-cleanup" - cleanup := setupTmuxSession(t, name) - defer cleanup() - - p := NewPool() - defer p.CloseAll() - - sess1, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("First Attach failed: %v", err) - } - - // Kill the process to simulate a dead session - sess1.Cmd.Process.Kill() - sess1.Cmd.Process.Wait() - time.Sleep(100 * time.Millisecond) - - // Re-create the tmux session since killing the attach killed our handle - exec.Command("tmux", "kill-session", "-t", name).Run() - setupCmd := exec.Command("tmux", "new-session", "-d", "-s", name, "-x", "80", "-y", "24") - if err := setupCmd.Run(); err != nil { - t.Fatalf("failed to recreate tmux session: %v", err) - } - - sess2, err := p.Attach(name, 80, 24) - if err != nil { - t.Fatalf("Second Attach after dead session failed: %v", err) - } - - if sess1 == sess2 { - t.Fatal("Attach should create new session when old one is dead") - } -} diff --git a/muxterm/protocol_test.go b/muxterm/protocol_test.go index 596e393..d6dc149 100644 --- a/muxterm/protocol_test.go +++ b/muxterm/protocol_test.go @@ -5,102 +5,8 @@ import ( "testing" ) -// --- Client-to-server message type tests --- - -func TestAttachMsgJSON(t *testing.T) { - msg := AttachMsg{Attach: "my-session"} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal AttachMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - if m["attach"] != "my-session" { - t.Errorf("expected attach=my-session, got %v", m["attach"]) - } -} - -func TestResizeMsgJSON(t *testing.T) { - msg := ResizeMsg{Resize: struct { - Cols int `json:"cols"` - Rows int `json:"rows"` - }{Cols: 80, Rows: 24}} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal ResizeMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - resize, ok := m["resize"].(map[string]interface{}) - if !ok { - t.Fatalf("expected resize to be a map, got %T", m["resize"]) - } - if resize["cols"].(float64) != 80 { - t.Errorf("expected cols=80, got %v", resize["cols"]) - } - if resize["rows"].(float64) != 24 { - t.Errorf("expected rows=24, got %v", resize["rows"]) - } -} - -func TestDetachMsgJSON(t *testing.T) { - msg := DetachMsg{Detach: true} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal DetachMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - if m["detach"] != true { - t.Errorf("expected detach=true, got %v", m["detach"]) - } -} - -// --- Server-to-client message type tests --- - -func TestAttachedMsgJSON(t *testing.T) { - msg := AttachedMsg{Attached: "sess1"} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal AttachedMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - if m["attached"] != "sess1" { - t.Errorf("expected attached=sess1, got %v", m["attached"]) - } -} - -func TestErrorMsgJSON(t *testing.T) { - msg := ErrorMsg{Error: "something broke"} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal ErrorMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - if m["error"] != "something broke" { - t.Errorf("expected error='something broke', got %v", m["error"]) - } -} - -func TestExitedMsgJSON(t *testing.T) { - msg := ExitedMsg{Exited: "sess1"} - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("marshal ExitedMsg: %v", err) - } - var m map[string]interface{} - json.Unmarshal(data, &m) - if m["exited"] != "sess1" { - t.Errorf("expected exited=sess1, got %v", m["exited"]) - } -} - -// --- ParseControlMessage tests --- - -func TestParseControlMessage_Attach(t *testing.T) { - data := []byte(`{"attach":"dev"}`) +func TestParseAttachMessage(t *testing.T) { + data := []byte(`{"attach": "dev-server"}`) kind, val := ParseControlMessage(data) if kind != "attach" { t.Fatalf("expected kind=attach, got %q", kind) @@ -109,13 +15,13 @@ func TestParseControlMessage_Attach(t *testing.T) { if !ok { t.Fatalf("expected *AttachMsg, got %T", val) } - if msg.Attach != "dev" { - t.Errorf("expected Attach=dev, got %q", msg.Attach) + if msg.Attach != "dev-server" { + t.Errorf("expected Attach=%q, got %q", "dev-server", msg.Attach) } } -func TestParseControlMessage_Resize(t *testing.T) { - data := []byte(`{"resize":{"cols":120,"rows":40}}`) +func TestParseResizeMessage(t *testing.T) { + data := []byte(`{"resize": {"cols": 120, "rows": 40}}`) kind, val := ParseControlMessage(data) if kind != "resize" { t.Fatalf("expected kind=resize, got %q", kind) @@ -124,13 +30,16 @@ func TestParseControlMessage_Resize(t *testing.T) { if !ok { t.Fatalf("expected *ResizeMsg, got %T", val) } - if msg.Resize.Cols != 120 || msg.Resize.Rows != 40 { - t.Errorf("expected 120x40, got %dx%d", msg.Resize.Cols, msg.Resize.Rows) + if msg.Resize.Cols != 120 { + t.Errorf("expected Cols=120, got %d", msg.Resize.Cols) + } + if msg.Resize.Rows != 40 { + t.Errorf("expected Rows=40, got %d", msg.Resize.Rows) } } -func TestParseControlMessage_Detach(t *testing.T) { - data := []byte(`{"detach":true}`) +func TestParseDetachMessage(t *testing.T) { + data := []byte(`{"detach": true}`) kind, val := ParseControlMessage(data) if kind != "detach" { t.Fatalf("expected kind=detach, got %q", kind) @@ -144,19 +53,8 @@ func TestParseControlMessage_Detach(t *testing.T) { } } -func TestParseControlMessage_Unknown(t *testing.T) { - data := []byte(`{"foo":"bar"}`) - kind, val := ParseControlMessage(data) - if kind != "unknown" { - t.Errorf("expected kind=unknown, got %q", kind) - } - if val != nil { - t.Errorf("expected nil val for unknown, got %v", val) - } -} - -func TestParseControlMessage_Invalid(t *testing.T) { - data := []byte(`not json at all`) +func TestParseInvalidJSON(t *testing.T) { + data := []byte(`not json`) kind, val := ParseControlMessage(data) if kind != "invalid" { t.Errorf("expected kind=invalid, got %q", kind) @@ -166,37 +64,46 @@ func TestParseControlMessage_Invalid(t *testing.T) { } } -// --- Marshal function tests --- +func TestParseUnknownMessage(t *testing.T) { + data := []byte(`{"foo": "bar"}`) + kind, val := ParseControlMessage(data) + if kind != "unknown" { + t.Errorf("expected kind=unknown, got %q", kind) + } + if val != nil { + t.Errorf("expected nil val for unknown, got %v", val) + } +} func TestMarshalAttached(t *testing.T) { - data := MarshalAttached("sess1") - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { + data := MarshalAttached("dev-server") + var msg AttachedMsg + if err := json.Unmarshal(data, &msg); err != nil { t.Fatalf("unmarshal: %v", err) } - if m["attached"] != "sess1" { - t.Errorf("expected attached=sess1, got %v", m["attached"]) + if msg.Attached != "dev-server" { + t.Errorf("expected Attached=%q, got %q", "dev-server", msg.Attached) } } func TestMarshalError(t *testing.T) { - data := MarshalError("bad thing") - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { + data := MarshalError("something broke") + var msg ErrorMsg + if err := json.Unmarshal(data, &msg); err != nil { t.Fatalf("unmarshal: %v", err) } - if m["error"] != "bad thing" { - t.Errorf("expected error='bad thing', got %v", m["error"]) + if msg.Error != "something broke" { + t.Errorf("expected Error=%q, got %q", "something broke", msg.Error) } } func TestMarshalExited(t *testing.T) { - data := MarshalExited("sess1") - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { + data := MarshalExited("dev-server") + var msg ExitedMsg + if err := json.Unmarshal(data, &msg); err != nil { t.Fatalf("unmarshal: %v", err) } - if m["exited"] != "sess1" { - t.Errorf("expected exited=sess1, got %v", m["exited"]) + if msg.Exited != "dev-server" { + t.Errorf("expected Exited=%q, got %q", "dev-server", msg.Exited) } -} \ No newline at end of file +} diff --git a/muxterm/ws_test.go b/muxterm/ws_test.go index e300e31..7d9705b 100644 --- a/muxterm/ws_test.go +++ b/muxterm/ws_test.go @@ -5,13 +5,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "net/http" - "net/http/httptest" - "strings" "testing" "time" - - "github.com/gorilla/websocket" ) // makeToken creates a valid HMAC-SHA256 token: sig.timestamp @@ -23,7 +18,7 @@ func makeToken(secret string, ts int64) string { return sig + "." + tsStr } -func TestValidateToken_ValidToken(t *testing.T) { +func TestValidateToken_Valid(t *testing.T) { secret := "test-secret" ts := time.Now().Unix() token := makeToken(secret, ts) @@ -33,139 +28,34 @@ func TestValidateToken_ValidToken(t *testing.T) { } } -func TestValidateToken_ExpiredToken(t *testing.T) { +func TestValidateToken_Expired(t *testing.T) { secret := "test-secret" - ts := time.Now().Unix() - 60 // 60 seconds ago, TTL is 30 + ts := time.Now().Unix() - 60 // 60 seconds ago token := makeToken(secret, ts) if ValidateToken(token, secret, 30) { - t.Error("expected expired token to fail validation") + t.Error("expected expired token (60s ago) to be rejected") } } -func TestValidateToken_FutureToken_WithinSkew(t *testing.T) { - secret := "test-secret" - ts := time.Now().Unix() + 3 // 3 seconds in future, within 5s skew - token := makeToken(secret, ts) - - if !ValidateToken(token, secret, 30) { - t.Error("expected near-future token within clock skew to pass") - } -} - -func TestValidateToken_FutureToken_BeyondSkew(t *testing.T) { - secret := "test-secret" - ts := time.Now().Unix() + 10 // 10 seconds in future, beyond 5s skew - token := makeToken(secret, ts) - - if ValidateToken(token, secret, 30) { - t.Error("expected far-future token beyond clock skew to fail") - } -} - -func TestValidateToken_BadSignature(t *testing.T) { +func TestValidateToken_WrongSecret(t *testing.T) { ts := time.Now().Unix() token := makeToken("right-secret", ts) if ValidateToken(token, "wrong-secret", 30) { - t.Error("expected wrong secret to fail validation") + t.Error("expected token signed with wrong secret to be rejected") } } -func TestValidateToken_MalformedToken(t *testing.T) { - if ValidateToken("not-a-valid-token", "secret", 30) { - t.Error("expected malformed token (no dot) to fail") - } - if ValidateToken("abc.def", "secret", 30) { - t.Error("expected non-numeric timestamp to fail") +func TestValidateToken_MalformedNoTimestamp(t *testing.T) { + // No dot separator — cannot split into sig.timestamp + if ValidateToken("nodothere", "secret", 30) { + t.Error("expected malformed token (no dot) to be rejected") } +} + +func TestValidateToken_EmptyToken(t *testing.T) { if ValidateToken("", "secret", 30) { - t.Error("expected empty token to fail") + t.Error("expected empty token to be rejected") } } - -func TestHandleWebSocket_RejectsNoToken(t *testing.T) { - pool := NewPool() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - HandleWebSocket(pool, "test-secret", w, r) - })) - defer server.Close() - - // Try to connect without a token - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?token=" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err == nil { - t.Error("expected connection to fail without valid token") - } - if resp != nil && resp.StatusCode != http.StatusForbidden { - t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) - } -} - -func TestHandleWebSocket_RejectsExpiredToken(t *testing.T) { - pool := NewPool() - secret := "test-secret" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - HandleWebSocket(pool, secret, w, r) - })) - defer server.Close() - - ts := time.Now().Unix() - 60 - token := makeToken(secret, ts) - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?token=" + token - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err == nil { - t.Error("expected connection to fail with expired token") - } - if resp != nil && resp.StatusCode != http.StatusForbidden { - t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) - } -} - -func TestHandleWebSocket_AcceptsValidToken(t *testing.T) { - pool := NewPool() - secret := "test-secret" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - HandleWebSocket(pool, secret, w, r) - })) - defer server.Close() - - ts := time.Now().Unix() - token := makeToken(secret, ts) - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?token=" + token - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("expected connection to succeed with valid token, got: %v", err) - } - defer conn.Close() -} - -func TestHandleWebSocket_DetachMessage(t *testing.T) { - pool := NewPool() - secret := "test-secret" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - HandleWebSocket(pool, secret, w, r) - })) - defer server.Close() - - ts := time.Now().Unix() - token := makeToken(secret, ts) - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?token=" + token - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("expected connection to succeed, got: %v", err) - } - defer conn.Close() - - // Send detach - should not crash, just clear state - err = conn.WriteMessage(websocket.TextMessage, []byte(`{"detach":true}`)) - if err != nil { - t.Fatalf("failed to send detach message: %v", err) - } -} - -func TestSendSIGWINCH_NilProcess(t *testing.T) { - // Should not panic with nil Cmd or nil Process - sess := &Session{} - sendSIGWINCH(sess) // should not panic -} \ No newline at end of file