package handlers import ( "crypto/sha256" "database/sql" "net/http" "net/http/httptest" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/Molecule-AI/molecule-monorepo/platform/internal/ws" "github.com/gin-gonic/gin" ) // newSocketHandlerWithDB creates a SocketHandler with buffered Hub channels. // The DB is set up via setupTestDB (called before this function in each test). func newSocketHandlerWithDB(t *testing.T, hub *ws.Hub) *SocketHandler { t.Helper() if hub == nil { hub = &ws.Hub{ Register: make(chan *ws.Client, 1), Unregister: make(chan *ws.Client, 1), } } return NewSocketHandler(hub) } // socketRequest builds a test request for the WebSocket connect endpoint. func socketRequest(method, path, workspaceID, authHeader string) *http.Request { req := httptest.NewRequest(method, path, nil) if workspaceID != "" { req.Header.Set("X-Workspace-ID", workspaceID) } if authHeader != "" { req.Header.Set("Authorization", authHeader) } return req } // ───────────────────────────────────────────────────────────────────────────── // Auth gate: DB error on HasAnyLiveToken → 500 // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_AuthGate_DBError_Returns500(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) // HasAnyLiveToken issues a query; make it return an error. mock.ExpectQuery("SELECT COUNT"). WithArgs("ws-auth-db-err"). WillReturnError(sql.ErrConnDone) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", "ws-auth-db-err", "") handler.HandleConnect(c) if w.Code != http.StatusInternalServerError { t.Errorf("DB error: expected 500, got %d", w.Code) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet mock expectations: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Auth gate: workspace HAS live token, missing Bearer → 401 // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_AuthGate_HasLiveToken_MissingBearer_Returns401(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) // HasAnyLiveToken succeeds → workspace has a live token. mock.ExpectQuery("SELECT COUNT"). WithArgs("ws-has-token-no-bearer"). WillReturnRows(sqlmock.NewRows([]string{"n"}).AddRow(1)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", "ws-has-token-no-bearer", "") handler.HandleConnect(c) if w.Code != http.StatusUnauthorized { t.Errorf("hasLive but no bearer: expected 401, got %d", w.Code) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet mock expectations: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Auth gate: workspace HAS live token, invalid Bearer → 401 // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_AuthGate_HasLiveToken_InvalidBearer_Returns401(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) wsID := "ws-invalid-token" badToken := "not-a-valid-token" // HasAnyLiveToken: workspace has a live token. mock.ExpectQuery("SELECT COUNT"). WithArgs(wsID). WillReturnRows(sqlmock.NewRows([]string{"n"}).AddRow(1)) // ValidateToken: lookupTokenByHash returns ErrNoRows for an unknown token. // Any token hash is fine since the token doesn't exist — use AnyArg. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN`). WithArgs(sqlmock.AnyArg()). WillReturnError(sql.ErrNoRows) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", wsID, "Bearer "+badToken) handler.HandleConnect(c) if w.Code != http.StatusUnauthorized { t.Errorf("hasLive but invalid bearer: expected 401, got %d", w.Code) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet mock expectations: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Auth gate: workspace HAS live token, VALID Bearer → upgrade attempted. // The WebSocket upgrade itself will fail in httptest (gorilla/websocket // cannot write a real HTTP/1.1 handshake to httptest.ResponseRecorder), but // the auth gate is passed so we verify no 401/500 was returned before the // upgrade failure. This is the canvas-client success path. // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_AuthGate_HasLiveToken_ValidBearer_AuthPassed(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) wsID := "ws-valid-token" goodToken := "valid-ws-token-123" // HasAnyLiveToken: workspace has a live token. mock.ExpectQuery("SELECT COUNT"). WithArgs(wsID). WillReturnRows(sqlmock.NewRows([]string{"n"}).AddRow(1)) // ValidateToken: token found and workspace is not removed. // sha256TokenHash returns []byte; rational matcher compares as string. mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN`). WithArgs(sha256TokenHash(goodToken)). WillReturnRows(sqlmock.NewRows([]string{"token_id", "workspace_id"}). AddRow("tok-abc", wsID)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", wsID, "Bearer "+goodToken) handler.HandleConnect(c) // The WebSocket upgrade fails in httptest (httptest.ResponseRecorder is not // a real TCP connection), but the auth gate itself succeeded — we should // NOT see a 401 or 500 response code. The actual code depends on the // upgrade error handling; the critical assertion is that auth passed. if w.Code == http.StatusUnauthorized || w.Code == http.StatusInternalServerError { t.Errorf("valid token: auth should have passed; got %d", w.Code) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet mock expectations: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Canvas client (no X-Workspace-ID): auth gate bypassed, upgrade attempted. // Same httptest limitation as above — we verify no 401/500 before the upgrade. // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_CanvasClient_NoAuthGate(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) // No X-Workspace-ID header → no auth check → no DB queries expected. w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", "", "") // no workspace ID handler.HandleConnect(c) // No auth gate hit → no 401/500. The WebSocket upgrade itself will fail // in httptest, but that's expected (see TestSocketHandler_AuthGate_HasLiveToken_ValidBearer_AuthPassed). if w.Code == http.StatusUnauthorized || w.Code == http.StatusInternalServerError { t.Errorf("canvas client: expected no auth error; got %d", w.Code) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet mock expectations: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Legacy workspace: HAS live token flag but workspace exists AND ValidateToken // is called. Since the workspace has a live token, the handler MUST validate // the presented token (not grandfather through). This is the Phase 30.1/30.2 // contract — a workspace with tokens on file is NOT grandfathered. // ───────────────────────────────────────────────────────────────────────────── func TestSocketHandler_AuthGate_HasLiveToken_EmptyBearer_Returns401(t *testing.T) { mock := setupTestDB(t) handler := newSocketHandlerWithDB(t, nil) wsID := "ws-has-live-token-empty-bearer" // HasAnyLiveToken: workspace has a live token. mock.ExpectQuery("SELECT COUNT"). WithArgs(wsID). WillReturnRows(sqlmock.NewRows([]string{"n"}).AddRow(1)) // Authorization header is "Bearer " (empty token after "Bearer "). // wsauth.BearerTokenFromHeader strips "Bearer " and gets "". // ValidateToken is called with "" → returns ErrInvalidToken before DB hit. w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = socketRequest("GET", "/ws", wsID, "Bearer ") handler.HandleConnect(c) if w.Code != http.StatusUnauthorized { t.Errorf("empty bearer after Bearer prefix: expected 401, got %d", w.Code) } } // ───────────────────────────────────────────────────────────────────────────── // helpers // ───────────────────────────────────────────────────────────────────────────── // sha256TokenHash returns the SHA256 hash of a plaintext token, matching what // wsauth.ValidateToken does internally before querying the DB. func sha256TokenHash(plaintext string) []byte { h := sha256.Sum256([]byte(plaintext)) return h[:] }