|
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
|
|
@@ -399,6 +400,172 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------- CWE-400: Resource consumption --------
|
|
|
|
|
|
|
|
|
|
// TestBroadcast_MessageTooLong verifies that messages exceeding 1000 characters
|
|
|
|
|
// are rejected with 400.
|
|
|
|
|
func TestBroadcast_MessageTooLong(t *testing.T) {
|
|
|
|
|
mock := setupTestDB(t)
|
|
|
|
|
handler := NewBroadcastHandler(newTestBroadcaster())
|
|
|
|
|
|
|
|
|
|
senderID := "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
|
|
|
|
|
// No DB queries should be reached — validation fails first.
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
|
|
|
|
longMsg := `{"message":"` + strings.Repeat("x", 1001) + `"}`
|
|
|
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(longMsg))
|
|
|
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
handler.Broadcast(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var resp map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp["error"] != "message too long (max 1000 characters)" {
|
|
|
|
|
t.Errorf("unexpected error: %v", resp["error"])
|
|
|
|
|
}
|
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
|
|
|
t.Errorf("unmet mock expectations: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBroadcast_RateLimitExceeded verifies that a sender exceeding 3 broadcasts
|
|
|
|
|
// per minute receives a 429.
|
|
|
|
|
func TestBroadcast_RateLimitExceeded(t *testing.T) {
|
|
|
|
|
mock := setupTestDB(t)
|
|
|
|
|
handler := NewBroadcastHandler(newTestBroadcaster())
|
|
|
|
|
|
|
|
|
|
senderID := "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
|
|
|
|
|
// Rate-limit count query: 3 prior broadcasts in the last minute.
|
|
|
|
|
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM activity_logs`).
|
|
|
|
|
WithArgs(senderID, sqlmock.AnyArg()).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
|
|
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(`{"message":"spammer"}`))
|
|
|
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
handler.Broadcast(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusTooManyRequests {
|
|
|
|
|
t.Errorf("expected 429, got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var resp map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
|
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if resp["error"] != "rate_limited" {
|
|
|
|
|
t.Errorf("unexpected error: %v", resp["error"])
|
|
|
|
|
}
|
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
|
|
|
t.Errorf("unmet mock expectations: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBroadcast_RateLimit_FailsOpen verifies that a rate-limit count query
|
|
|
|
|
// error does NOT block the broadcast — we fail open so a DB hiccup doesn't
|
|
|
|
|
// silently DoS all broadcasts.
|
|
|
|
|
func TestBroadcast_RateLimit_FailsOpen(t *testing.T) {
|
|
|
|
|
mock := setupTestDB(t)
|
|
|
|
|
handler := NewBroadcastHandler(newTestBroadcaster())
|
|
|
|
|
|
|
|
|
|
senderID := "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
|
|
|
|
|
// Rate-limit query errors — we fail open and continue.
|
|
|
|
|
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM activity_logs`).
|
|
|
|
|
WithArgs(senderID, sqlmock.AnyArg()).
|
|
|
|
|
WillReturnError(context.DeadlineExceeded)
|
|
|
|
|
|
|
|
|
|
// Normal broadcast flow continues.
|
|
|
|
|
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id`).
|
|
|
|
|
WithArgs(senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Agent", true))
|
|
|
|
|
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
|
|
|
|
WithArgs(senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
|
|
|
|
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
|
|
|
|
WithArgs(senderID, senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"})) // no recipients
|
|
|
|
|
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
|
|
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(`{"message":"hello"}`))
|
|
|
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
handler.Broadcast(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("expected 200 (fail-open), got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
|
|
|
t.Errorf("unmet mock expectations: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------- CWE-79: Stored XSS --------
|
|
|
|
|
|
|
|
|
|
// TestBroadcast_XSSCharactersEscaped verifies that < > & in the message are
|
|
|
|
|
// HTML-escaped before being stored in activity_logs and broadcast via WebSocket.
|
|
|
|
|
func TestBroadcast_XSSCharactersEscaped(t *testing.T) {
|
|
|
|
|
mock := setupTestDB(t)
|
|
|
|
|
handler := NewBroadcastHandler(newTestBroadcaster())
|
|
|
|
|
|
|
|
|
|
senderID := "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
peerID := "00000000-0000-0000-0000-000000000002"
|
|
|
|
|
|
|
|
|
|
// Rate-limit count: 0
|
|
|
|
|
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM activity_logs`).
|
|
|
|
|
WithArgs(senderID, sqlmock.AnyArg()).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
|
|
|
// Sender lookup
|
|
|
|
|
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id`).
|
|
|
|
|
WithArgs(senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Agent", true))
|
|
|
|
|
// Org root
|
|
|
|
|
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
|
|
|
|
WithArgs(senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
|
|
|
|
// Recipients
|
|
|
|
|
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
|
|
|
|
WithArgs(senderID, senderID).
|
|
|
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerID))
|
|
|
|
|
// Activity log insert — verify the summary contains escaped content.
|
|
|
|
|
// broadcastTruncate(100 chars) applied to the escaped string.
|
|
|
|
|
// Raw: "<script>alert('xss')</script>" → escaped: "<script>alert('xss')</script>"
|
|
|
|
|
malicious := `{"message":"<script>alert('xss')</script>"}`
|
|
|
|
|
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
|
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
c, _ := gin.CreateTestContext(w)
|
|
|
|
|
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
|
|
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(malicious))
|
|
|
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
handler.Broadcast(c)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
// The handler must not panic on XSS input and must not store raw HTML.
|
|
|
|
|
// The actual DB content is verified by AnyArg() — a real integration test
|
|
|
|
|
// against Postgres would assert the row contains < not <.
|
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
|
|
|
t.Errorf("unmet mock expectations: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
|
|
|
|
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
|
|
|
|
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
|
|
|
|