Merge pull request 'test(handlers/socket): add socket_test.go — 6 cases for Phase 30.1/30.2 auth gate' (#699) from feat/socket-handler-test-coverage into main
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
Harness Replays / detect-changes (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
CI / Detect changes (push) Successful in 13s
Harness Replays / Harness Replays (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
qa-review / approved (pull_request) Failing after 14s
gate-check-v3 / gate-check (pull_request) Successful in 23s
CI / Detect changes (pull_request) Successful in 52s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 48s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 48s
E2E API Smoke Test / detect-changes (pull_request) Successful in 50s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 48s
security-review / approved (pull_request) Failing after 15s
CI / Platform (Go) (pull_request) Successful in 7s
sop-checklist-gate / gate (pull_request) Successful in 15s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 18s
CI / Python Lint & Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m16s
publish-workspace-server-image / build-and-push (push) Successful in 6m57s
CI / Platform (Go) (push) Failing after 9m4s
CI / all-required (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
main-red-watchdog / watchdog (push) Successful in 27s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 1m7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
Harness Replays / detect-changes (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
CI / Detect changes (push) Successful in 13s
Harness Replays / Harness Replays (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
qa-review / approved (pull_request) Failing after 14s
gate-check-v3 / gate-check (pull_request) Successful in 23s
CI / Detect changes (pull_request) Successful in 52s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 48s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 48s
E2E API Smoke Test / detect-changes (pull_request) Successful in 50s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 48s
security-review / approved (pull_request) Failing after 15s
CI / Platform (Go) (pull_request) Successful in 7s
sop-checklist-gate / gate (pull_request) Successful in 15s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 18s
CI / Python Lint & Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m16s
publish-workspace-server-image / build-and-push (push) Successful in 6m57s
CI / Platform (Go) (push) Failing after 9m4s
CI / all-required (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
main-red-watchdog / watchdog (push) Successful in 27s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 1m7s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
This commit is contained in:
commit
ddba57e3f6
242
workspace-server/internal/handlers/socket_test.go
Normal file
242
workspace-server/internal/handlers/socket_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
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[:]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user