- Added unit tests for CrowdSec handler, including listing, banning, and unbanning IPs. - Implemented mock command executor for testing command execution. - Created tests for various scenarios including successful operations, error handling, and invalid inputs. - Developed CrowdSec configuration tests to ensure proper handler setup and JSON output. - Documented security features and identified gaps in CrowdSec, WAF, and Rate Limiting implementations. - Established acceptance criteria for feature completeness and outlined implementation phases for future work.
451 lines
12 KiB
Go
451 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// mockCommandExecutor is a mock implementation of CommandExecutor for testing
|
|
type mockCommandExecutor struct {
|
|
output []byte
|
|
err error
|
|
calls [][]string // Track all calls made
|
|
}
|
|
|
|
func (m *mockCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
call := append([]string{name}, args...)
|
|
m.calls = append(m.calls, call)
|
|
return m.output, m.err
|
|
}
|
|
|
|
func TestListDecisions_Success(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte(`[{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual 'ban' from 'localhost'","created_at":"2025-12-05T10:00:00Z","until":"2025-12-05T14:00:00Z"}]`),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
decisions := resp["decisions"].([]interface{})
|
|
assert.Len(t, decisions, 1)
|
|
|
|
decision := decisions[0].(map[string]interface{})
|
|
assert.Equal(t, "192.168.1.100", decision["value"])
|
|
assert.Equal(t, "ban", decision["type"])
|
|
assert.Equal(t, "ip", decision["scope"])
|
|
|
|
// Verify cscli was called with correct args
|
|
require.Len(t, mockExec.calls, 1)
|
|
assert.Equal(t, []string{"cscli", "decisions", "list", "-o", "json"}, mockExec.calls[0])
|
|
}
|
|
|
|
func TestListDecisions_EmptyList(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte("null"),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
decisions := resp["decisions"].([]interface{})
|
|
assert.Len(t, decisions, 0)
|
|
assert.Equal(t, float64(0), resp["total"])
|
|
}
|
|
|
|
func TestListDecisions_CscliError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
err: errors.New("cscli not found"),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should return 200 with empty list and error message
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
decisions := resp["decisions"].([]interface{})
|
|
assert.Len(t, decisions, 0)
|
|
assert.Contains(t, resp["error"], "cscli not available")
|
|
}
|
|
|
|
func TestListDecisions_InvalidJSON(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte("invalid json"),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.Contains(t, w.Body.String(), "failed to parse decisions")
|
|
}
|
|
|
|
func TestBanIP_Success(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte(""),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
payload := BanIPRequest{
|
|
IP: "192.168.1.100",
|
|
Duration: "24h",
|
|
Reason: "suspicious activity",
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "banned", resp["status"])
|
|
assert.Equal(t, "192.168.1.100", resp["ip"])
|
|
assert.Equal(t, "24h", resp["duration"])
|
|
|
|
// Verify cscli was called with correct args
|
|
require.Len(t, mockExec.calls, 1)
|
|
assert.Equal(t, "cscli", mockExec.calls[0][0])
|
|
assert.Equal(t, "decisions", mockExec.calls[0][1])
|
|
assert.Equal(t, "add", mockExec.calls[0][2])
|
|
assert.Equal(t, "-i", mockExec.calls[0][3])
|
|
assert.Equal(t, "192.168.1.100", mockExec.calls[0][4])
|
|
assert.Equal(t, "-d", mockExec.calls[0][5])
|
|
assert.Equal(t, "24h", mockExec.calls[0][6])
|
|
assert.Equal(t, "-R", mockExec.calls[0][7])
|
|
assert.Equal(t, "manual ban: suspicious activity", mockExec.calls[0][8])
|
|
}
|
|
|
|
func TestBanIP_DefaultDuration(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte(""),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
payload := BanIPRequest{
|
|
IP: "10.0.0.1",
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
// Duration should default to 24h
|
|
assert.Equal(t, "24h", resp["duration"])
|
|
|
|
// Verify cscli was called with default duration
|
|
require.Len(t, mockExec.calls, 1)
|
|
assert.Equal(t, "24h", mockExec.calls[0][6])
|
|
}
|
|
|
|
func TestBanIP_MissingIP(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
payload := map[string]string{}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "ip is required")
|
|
}
|
|
|
|
func TestBanIP_EmptyIP(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
payload := BanIPRequest{
|
|
IP: " ",
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "ip cannot be empty")
|
|
}
|
|
|
|
func TestBanIP_CscliError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
err: errors.New("cscli failed"),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
payload := BanIPRequest{
|
|
IP: "192.168.1.100",
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.Contains(t, w.Body.String(), "failed to ban IP")
|
|
}
|
|
|
|
func TestUnbanIP_Success(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte(""),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "unbanned", resp["status"])
|
|
assert.Equal(t, "192.168.1.100", resp["ip"])
|
|
|
|
// Verify cscli was called with correct args
|
|
require.Len(t, mockExec.calls, 1)
|
|
assert.Equal(t, []string{"cscli", "decisions", "delete", "-i", "192.168.1.100"}, mockExec.calls[0])
|
|
}
|
|
|
|
func TestUnbanIP_CscliError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
err: errors.New("cscli failed"),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.Contains(t, w.Body.String(), "failed to unban IP")
|
|
}
|
|
|
|
func TestListDecisions_MultipleDecisions(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
mockExec := &mockCommandExecutor{
|
|
output: []byte(`[
|
|
{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual ban","created_at":"2025-12-05T10:00:00Z"},
|
|
{"id":2,"origin":"crowdsec","type":"ban","scope":"ip","value":"10.0.0.50","duration":"1h","scenario":"ssh-bf","created_at":"2025-12-05T11:00:00Z"},
|
|
{"id":3,"origin":"cscli","type":"ban","scope":"range","value":"172.16.0.0/24","duration":"24h","scenario":"manual ban","created_at":"2025-12-05T12:00:00Z"}
|
|
]`),
|
|
}
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
|
|
decisions := resp["decisions"].([]interface{})
|
|
assert.Len(t, decisions, 3)
|
|
assert.Equal(t, float64(3), resp["total"])
|
|
|
|
// Verify each decision
|
|
d1 := decisions[0].(map[string]interface{})
|
|
assert.Equal(t, "192.168.1.100", d1["value"])
|
|
assert.Equal(t, "cscli", d1["origin"])
|
|
|
|
d2 := decisions[1].(map[string]interface{})
|
|
assert.Equal(t, "10.0.0.50", d2["value"])
|
|
assert.Equal(t, "crowdsec", d2["origin"])
|
|
assert.Equal(t, "ssh-bf", d2["scenario"])
|
|
|
|
d3 := decisions[2].(map[string]interface{})
|
|
assert.Equal(t, "172.16.0.0/24", d3["value"])
|
|
assert.Equal(t, "range", d3["scope"])
|
|
}
|
|
|
|
func TestBanIP_InvalidJSON(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader([]byte("invalid json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "ip is required")
|
|
}
|