test(muxterm): unit tests for protocol, token validation, and pool

This commit is contained in:
Ken
2026-05-28 06:17:50 +00:00
parent f2d999973a
commit 11794c4e24
3 changed files with 80 additions and 417 deletions
+25 -159
View File
@@ -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)
}
if sess.Rows != 40 {
t.Errorf("After resize Rows = %d, want 40", sess.Rows)
resized := p.Get(name)
if resized.Cols != 120 {
t.Errorf("After resize Cols = %d, want 120", resized.Cols)
}
if resized.Rows != 40 {
t.Errorf("After resize Rows = %d, want 40", resized.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)
}
// 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")
}
}
+38 -131
View File
@@ -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,12 +15,12 @@ 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) {
func TestParseResizeMessage(t *testing.T) {
data := []byte(`{"resize": {"cols": 120, "rows": 40}}`)
kind, val := ParseControlMessage(data)
if kind != "resize" {
@@ -124,12 +30,15 @@ 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) {
func TestParseDetachMessage(t *testing.T) {
data := []byte(`{"detach": true}`)
kind, val := ParseControlMessage(data)
if kind != "detach" {
@@ -144,7 +53,18 @@ func TestParseControlMessage_Detach(t *testing.T) {
}
}
func TestParseControlMessage_Unknown(t *testing.T) {
func TestParseInvalidJSON(t *testing.T) {
data := []byte(`not json`)
kind, val := ParseControlMessage(data)
if kind != "invalid" {
t.Errorf("expected kind=invalid, got %q", kind)
}
if val != nil {
t.Errorf("expected nil val for invalid, got %v", val)
}
}
func TestParseUnknownMessage(t *testing.T) {
data := []byte(`{"foo": "bar"}`)
kind, val := ParseControlMessage(data)
if kind != "unknown" {
@@ -155,48 +75,35 @@ func TestParseControlMessage_Unknown(t *testing.T) {
}
}
func TestParseControlMessage_Invalid(t *testing.T) {
data := []byte(`not json at all`)
kind, val := ParseControlMessage(data)
if kind != "invalid" {
t.Errorf("expected kind=invalid, got %q", kind)
}
if val != nil {
t.Errorf("expected nil val for invalid, got %v", val)
}
}
// --- Marshal function tests ---
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)
}
}
+13 -123
View File
@@ -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")
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")
}
if ValidateToken("abc.def", "secret", 30) {
t.Error("expected non-numeric timestamp to fail")
}
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
}