4727 lines
141 KiB
Go
4727 lines
141 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/crowdsec"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type fakeExec struct {
|
|
started bool
|
|
startErr error
|
|
}
|
|
|
|
func (f *fakeExec) Start(ctx context.Context, binPath, configDir string) (int, error) {
|
|
if f.startErr != nil {
|
|
return 0, f.startErr
|
|
}
|
|
f.started = true
|
|
return 12345, nil
|
|
}
|
|
func (f *fakeExec) Stop(ctx context.Context, configDir string) error {
|
|
f.started = false
|
|
return nil
|
|
}
|
|
func (f *fakeExec) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
|
|
if f.started {
|
|
return true, 12345, nil
|
|
}
|
|
return false, 0, nil
|
|
}
|
|
|
|
func setupCrowdDB(t *testing.T) *gorm.DB {
|
|
db := OpenTestDB(t)
|
|
// Migrate tables needed by CrowdSec handlers
|
|
if err := db.AutoMigrate(&models.SecurityConfig{}); err != nil {
|
|
t.Fatalf("failed to migrate SecurityConfig: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// fastCmdExec is a mock command executor that immediately returns success for LAPI checks
|
|
type fastCmdExec struct{}
|
|
|
|
func (f *fastCmdExec) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
// Return success for lapi status checks to avoid 60s timeout
|
|
return []byte("ok"), nil
|
|
}
|
|
|
|
// newTestCrowdsecHandler creates a CrowdsecHandler and registers cleanup to prevent goroutine leaks
|
|
func newTestCrowdsecHandler(t *testing.T, db *gorm.DB, executor CrowdsecExecutor, binPath string, dataDir string) *CrowdsecHandler {
|
|
h := NewCrowdsecHandler(db, executor, binPath, dataDir)
|
|
// Override CmdExec to avoid 60s LAPI wait timeout during Start
|
|
h.CmdExec = &fastCmdExec{}
|
|
// Set short timeouts for test performance
|
|
h.LAPIMaxWait = 100 * time.Millisecond
|
|
h.LAPIPollInterval = 10 * time.Millisecond
|
|
// Register cleanup to stop SecurityService goroutine
|
|
if h.Security != nil {
|
|
t.Cleanup(func() {
|
|
h.Security.Close()
|
|
})
|
|
}
|
|
return h
|
|
}
|
|
|
|
func TestCrowdsecEndpoints(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Status (initially stopped)
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status expected 200 got %d", w.Code)
|
|
}
|
|
|
|
// Start
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w2, req2)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("start expected 200 got %d", w2.Code)
|
|
}
|
|
|
|
// Stop
|
|
w3 := httptest.NewRecorder()
|
|
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
|
|
r.ServeHTTP(w3, req3)
|
|
if w3.Code != http.StatusOK {
|
|
t.Fatalf("stop expected 200 got %d", w3.Code)
|
|
}
|
|
}
|
|
|
|
func TestImportConfig(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Create a valid test archive
|
|
files := map[string]string{
|
|
"config.yaml": "api:\n server:\n listen_uri: 0.0.0.0:8080\n",
|
|
}
|
|
archivePath := createTestArchive(t, "tar.gz", files, true)
|
|
|
|
// Read archive and create multipart request
|
|
// #nosec G304 -- archivePath is in test temp directory created by t.TempDir()
|
|
archiveData, err := os.ReadFile(archivePath)
|
|
require.NoError(t, err)
|
|
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
fw, err := mw.CreateFormFile("file", "cfg.tar.gz")
|
|
require.NoError(t, err)
|
|
_, err = fw.Write(archiveData)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mw.Close())
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Ensure extracted config.yaml exists in data dir (not the archive file)
|
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
|
if _, err := os.Stat(configPath); err != nil {
|
|
t.Fatalf("expected config.yaml to be extracted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportCreatesBackup(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
// create existing config dir with a marker file
|
|
_ = os.MkdirAll(tmpDir, 0o750) // #nosec G301 -- test directory
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Create valid archive
|
|
files := map[string]string{
|
|
"config.yaml": "api:\n server:\n listen_uri: 0.0.0.0:8080\n",
|
|
}
|
|
archivePath := createTestArchive(t, "tar.gz", files, true)
|
|
// #nosec G304 -- archivePath is in test temp directory created by t.TempDir()
|
|
archiveData, err := os.ReadFile(archivePath)
|
|
require.NoError(t, err)
|
|
|
|
// Upload
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
fw, err := mw.CreateFormFile("file", "cfg.tar.gz")
|
|
require.NoError(t, err)
|
|
_, err = fw.Write(archiveData)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mw.Close())
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ensure backup dir exists (ends with .backup.TIMESTAMP)
|
|
found := false
|
|
entries, _ := os.ReadDir(filepath.Dir(tmpDir))
|
|
for _, e := range entries {
|
|
if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
// fallback: check for any .backup.* in same parent dir
|
|
entries, _ := os.ReadDir(filepath.Dir(tmpDir))
|
|
for _, e := range entries {
|
|
if e.IsDir() && filepath.Ext(e.Name()) == "" && e.Name() != "" && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) {
|
|
// best-effort assume backup present
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected backup directory next to data dir")
|
|
}
|
|
}
|
|
|
|
func TestExportConfig(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
// create some files to export
|
|
_ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o750) // #nosec G301 -- test directory
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o600) // #nosec G306 -- test fixture
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("export expected 200 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
|
|
t.Fatalf("unexpected content type: %s", ct)
|
|
}
|
|
if w.Body.Len() == 0 {
|
|
t.Fatalf("expected response body to contain archive data")
|
|
}
|
|
}
|
|
|
|
func TestListAndReadFile(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
// create a nested file
|
|
_ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o750) // #nosec G301 -- test directory
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o600) // #nosec G306 -- test fixture
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("files expected 200 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
// read a single file
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=conf.d/a.conf", http.NoBody)
|
|
r.ServeHTTP(w2, req2)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("file read expected 200 got %d body=%s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestExportConfigStreamsArchive(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
dataDir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("hello"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", dataDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
|
require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
|
|
|
|
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
|
require.NoError(t, err)
|
|
tr := tar.NewReader(gr)
|
|
found := false
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
if hdr.Name == "config.yaml" {
|
|
data, readErr := io.ReadAll(tr)
|
|
require.NoError(t, readErr)
|
|
require.Equal(t, "hello", string(data))
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "expected exported archive to contain config file")
|
|
}
|
|
|
|
func TestWriteFileCreatesBackup(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
// create existing config dir with a marker file
|
|
_ = os.MkdirAll(tmpDir, 0o750) // #nosec G301 -- test directory
|
|
_ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o600) // #nosec G306 -- test fixture
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// write content to new file
|
|
payload := map[string]string{"path": "conf.d/new.conf", "content": "hello world"}
|
|
b, _ := json.Marshal(payload)
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("write expected 200 got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ensure backup directory was created
|
|
entries, err := os.ReadDir(filepath.Dir(tmpDir))
|
|
require.NoError(t, err)
|
|
foundBackup := false
|
|
for _, e := range entries {
|
|
if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
|
foundBackup = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundBackup, "expected backup directory to be created")
|
|
}
|
|
|
|
func TestListPresetsCerberusDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 when cerberus disabled got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestReadFileInvalidPath(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../secret", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileInvalidPath(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body, _ := json.Marshal(map[string]string{"path": "../../escape", "content": "bad"})
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileMissingPath(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body, _ := json.Marshal(map[string]string{"content": "data only"})
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestWriteFileInvalidPayload(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewBufferString("not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestImportConfigRequiresFile(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 when file missing got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestImportConfigRejectsEmptyUpload(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
_, _ = mw.CreateFormFile("file", "empty.tgz")
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Empty upload now returns 422 (validation error) instead of 400
|
|
if w.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected 422 for empty upload got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListFilesMissingDir(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
missingDir := filepath.Join(t.TempDir(), "does-not-exist")
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for missing dir got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListFilesReturnsEntries(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
dataDir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o600)) // #nosec G306 -- test fixture
|
|
nestedDir := filepath.Join(dataDir, "nested")
|
|
require.NoError(t, os.MkdirAll(nestedDir, 0o750)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "child.txt"), []byte("child"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 got %d", w.Code)
|
|
}
|
|
|
|
var resp struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.ElementsMatch(t, []string{"root.txt", filepath.Join("nested", "child.txt")}, resp.Files)
|
|
}
|
|
|
|
func TestIsCerberusEnabledFromDB(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
|
require.NoError(t, db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "0"}).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 when cerberus disabled via DB got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestIsCerberusEnabledInvalidEnv(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "not-a-bool")
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
if h.isCerberusEnabled() {
|
|
t.Fatalf("expected cerberus to be disabled for invalid env flag")
|
|
}
|
|
}
|
|
|
|
func TestIsCerberusEnabledLegacyEnv(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
t.Setenv("CERBERUS_ENABLED", "0")
|
|
|
|
if h.isCerberusEnabled() {
|
|
t.Fatalf("expected cerberus to be disabled for legacy env flag")
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Console Enrollment Tests
|
|
// ============================================
|
|
|
|
type mockEnvExecutor struct {
|
|
responses []struct {
|
|
out []byte
|
|
err error
|
|
}
|
|
defaultResponse struct {
|
|
out []byte
|
|
err error
|
|
}
|
|
calls []struct {
|
|
name string
|
|
args []string
|
|
}
|
|
}
|
|
|
|
func (m *mockEnvExecutor) ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error) {
|
|
m.calls = append(m.calls, struct {
|
|
name string
|
|
args []string
|
|
}{name, args})
|
|
|
|
if len(m.calls) <= len(m.responses) {
|
|
resp := m.responses[len(m.calls)-1]
|
|
return resp.out, resp.err
|
|
}
|
|
return m.defaultResponse.out, m.defaultResponse.err
|
|
}
|
|
|
|
func setupTestConsoleEnrollment(t *testing.T) (*CrowdsecHandler, *mockEnvExecutor) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.CrowdsecConsoleEnrollment{}))
|
|
|
|
exec := &mockEnvExecutor{}
|
|
dataDir := t.TempDir()
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", dataDir)
|
|
// Replace the Console service with one that uses our mock executor
|
|
h.Console = crowdsec.NewConsoleEnrollmentService(db, exec, dataDir, "test-secret")
|
|
|
|
return h, exec
|
|
}
|
|
|
|
func TestConsoleEnrollDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "disabled")
|
|
}
|
|
|
|
func TestConsoleEnrollServiceUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
// Set Console to nil to simulate unavailable
|
|
h.Console = nil
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "unavailable")
|
|
}
|
|
|
|
func TestConsoleEnrollInvalidPayload(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader("not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "invalid payload")
|
|
}
|
|
|
|
func TestConsoleEnrollSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent", "tenant": "my-tenant"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
// Enrollment request sent, but user must accept on crowdsec.net
|
|
require.Equal(t, "pending_acceptance", resp["status"])
|
|
}
|
|
|
|
func TestConsoleEnrollMissingAgentName(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": ""}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "required")
|
|
}
|
|
|
|
func TestConsoleStatusDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "disabled")
|
|
}
|
|
|
|
func TestConsoleStatusServiceUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
// Set Console to nil to simulate unavailable
|
|
h.Console = nil
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "unavailable")
|
|
}
|
|
|
|
func TestConsoleStatusSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Get status when not enrolled yet
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "not_enrolled", resp["status"])
|
|
}
|
|
|
|
func TestConsoleStatusAfterEnroll(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// First enroll
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Then check status
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
require.Equal(t, http.StatusOK, w2.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp))
|
|
// Enrollment request sent, but user must accept on crowdsec.net
|
|
require.Equal(t, "pending_acceptance", resp["status"])
|
|
require.Equal(t, "test-agent", resp["agent_name"])
|
|
}
|
|
|
|
// ============================================
|
|
// isConsoleEnrollmentEnabled Tests
|
|
// ============================================
|
|
|
|
func TestIsConsoleEnrollmentEnabledFromDB(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
|
require.NoError(t, db.Create(&models.Setting{Key: "feature.crowdsec.console_enrollment", Value: "true"}).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.True(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentDisabledFromDB(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
|
require.NoError(t, db.Create(&models.Setting{Key: "feature.crowdsec.console_enrollment", Value: "false"}).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.False(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentEnabledFromEnv(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.True(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentDisabledFromEnv(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "0")
|
|
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.False(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentInvalidEnv(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "invalid")
|
|
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.False(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentDefaultDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, nil, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.False(t, h.isConsoleEnrollmentEnabled())
|
|
}
|
|
|
|
func TestIsConsoleEnrollmentDBTrueVariants(t *testing.T) {
|
|
tests := []struct {
|
|
value string
|
|
expected bool
|
|
}{
|
|
{"true", true},
|
|
{"TRUE", true},
|
|
{"True", true},
|
|
{"1", true},
|
|
{"yes", true},
|
|
{"YES", true},
|
|
{"false", false},
|
|
{"FALSE", false},
|
|
{"0", false},
|
|
{"no", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.value, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
|
require.NoError(t, db.Create(&models.Setting{Key: "feature.crowdsec.console_enrollment", Value: tc.value}).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
require.Equal(t, tc.expected, h.isConsoleEnrollmentEnabled(), "value %q", tc.value)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Bouncer Registration Tests
|
|
// ============================================
|
|
|
|
type mockCmdExecutor struct {
|
|
output []byte
|
|
err error
|
|
calls []struct {
|
|
name string
|
|
args []string
|
|
}
|
|
}
|
|
|
|
func (m *mockCmdExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
m.calls = append(m.calls, struct {
|
|
name string
|
|
args []string
|
|
}{name, args})
|
|
return m.output, m.err
|
|
}
|
|
|
|
func TestRegisterBouncerScriptNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/bouncer/register", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Script doesn't exist, should return 404
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "script not found")
|
|
}
|
|
|
|
func TestRegisterBouncerSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create a temp script that mimics successful bouncer registration
|
|
tmpDir := t.TempDir()
|
|
|
|
// Skip if we can't create the script in the expected location
|
|
if _, err := os.Stat("/usr/local/bin"); os.IsNotExist(err) {
|
|
t.Skip("Skipping test: /usr/local/bin does not exist")
|
|
}
|
|
|
|
// Create a mock command executor that simulates successful registration
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Bouncer registered successfully\nAPI Key: abc123456789abcdef0123456789abcdef\n"),
|
|
err: nil,
|
|
}
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
// We need the script to exist for the test to work
|
|
// Create a dummy script in tmpDir and modify the handler to check there
|
|
// For this test, we'll just verify the mock executor is called correctly
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// This will fail because script doesn't exist at /usr/local/bin/register_bouncer.sh
|
|
// The test verifies the handler's script-not-found behavior
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/bouncer/register", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func TestRegisterBouncerExecutionError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create a mock command executor that simulates execution error
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Error: failed to execute cscli"),
|
|
err: errors.New("exit status 1"),
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Script doesn't exist, so it will return 404 first
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/bouncer/register", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// ============================================
|
|
// Acquisition Config Tests
|
|
// ============================================
|
|
|
|
func TestGetAcquisitionConfigNotFound(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", filepath.Join(t.TempDir(), "missing-acquis.yaml"))
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "not found")
|
|
}
|
|
|
|
func TestGetAcquisitionConfigSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create a temp acquis.yaml to test with
|
|
tmpDir := t.TempDir()
|
|
acquisDir := filepath.Join(tmpDir, "crowdsec")
|
|
require.NoError(t, os.MkdirAll(acquisDir, 0o750)) // #nosec G301 -- test directory
|
|
|
|
acquisContent := `# Test acquisition config
|
|
source: file
|
|
filenames:
|
|
- /var/log/caddy/access.log
|
|
labels:
|
|
type: caddy
|
|
`
|
|
acquisPath := filepath.Join(acquisDir, "acquis.yaml")
|
|
require.NoError(t, os.WriteFile(acquisPath, []byte(acquisContent), 0o600)) // #nosec G306 -- test fixture
|
|
t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", acquisPath)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, acquisPath, resp["path"])
|
|
require.Equal(t, acquisContent, resp["content"])
|
|
}
|
|
|
|
// ============================================
|
|
// DeleteConsoleEnrollment Tests
|
|
// ============================================
|
|
|
|
func TestDeleteConsoleEnrollmentDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
// Feature flag not set, should return 404
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "disabled")
|
|
}
|
|
|
|
func TestDeleteConsoleEnrollmentServiceUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
// Create handler with nil Console service
|
|
db := OpenTestDB(t)
|
|
h := &CrowdsecHandler{
|
|
DB: db,
|
|
Executor: &fakeExec{},
|
|
CmdExec: &RealCommandExecutor{},
|
|
BinPath: "/bin/false",
|
|
DataDir: t.TempDir(),
|
|
Console: nil, // Explicitly nil
|
|
}
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "not available")
|
|
}
|
|
|
|
func TestDeleteConsoleEnrollmentSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
|
|
// First create an enrollment record
|
|
rec := &models.CrowdsecConsoleEnrollment{
|
|
UUID: "test-uuid",
|
|
Status: "enrolled",
|
|
AgentName: "test-agent",
|
|
Tenant: "test-tenant",
|
|
}
|
|
require.NoError(t, h.DB.Create(rec).Error)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Delete the enrollment
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Contains(t, w.Body.String(), "cleared")
|
|
|
|
// Verify the record is gone
|
|
var count int64
|
|
h.DB.Model(&models.CrowdsecConsoleEnrollment{}).Count(&count)
|
|
require.Equal(t, int64(0), count)
|
|
}
|
|
|
|
func TestDeleteConsoleEnrollmentNoRecordSuccess(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
|
|
// Don't create any record - deletion should still succeed
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Contains(t, w.Body.String(), "cleared")
|
|
}
|
|
|
|
func TestDeleteConsoleEnrollmentThenReenroll(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// First enroll
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent-1"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Check status shows pending_acceptance
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w2, req2)
|
|
require.Equal(t, http.StatusOK, w2.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp))
|
|
require.Equal(t, "pending_acceptance", resp["status"])
|
|
require.Equal(t, "test-agent-1", resp["agent_name"])
|
|
|
|
// Delete enrollment
|
|
w3 := httptest.NewRecorder()
|
|
req3 := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/console/enrollment", http.NoBody)
|
|
r.ServeHTTP(w3, req3)
|
|
require.Equal(t, http.StatusOK, w3.Code)
|
|
|
|
// Check status shows not_enrolled
|
|
w4 := httptest.NewRecorder()
|
|
req4 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w4, req4)
|
|
require.Equal(t, http.StatusOK, w4.Code)
|
|
var resp2 map[string]any
|
|
require.NoError(t, json.Unmarshal(w4.Body.Bytes(), &resp2))
|
|
require.Equal(t, "not_enrolled", resp2["status"])
|
|
|
|
// Re-enroll with NEW agent name - should work WITHOUT force
|
|
body2 := `{"enrollment_key": "newkey123456", "agent_name": "test-agent-2"}`
|
|
w5 := httptest.NewRecorder()
|
|
req5 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body2))
|
|
req5.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w5, req5)
|
|
require.Equal(t, http.StatusOK, w5.Code)
|
|
|
|
// Check status shows new agent name
|
|
w6 := httptest.NewRecorder()
|
|
req6 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/console/status", http.NoBody)
|
|
r.ServeHTTP(w6, req6)
|
|
require.Equal(t, http.StatusOK, w6.Code)
|
|
var resp3 map[string]any
|
|
require.NoError(t, json.Unmarshal(w6.Body.Bytes(), &resp3))
|
|
require.Equal(t, "pending_acceptance", resp3["status"])
|
|
require.Equal(t, "test-agent-2", resp3["agent_name"])
|
|
}
|
|
|
|
// ============================================
|
|
// NEW COVERAGE TESTS - Phase 3 Implementation
|
|
// ============================================
|
|
|
|
// Start Handler - LAPI Readiness Polling Tests
|
|
func TestCrowdsecStart_LAPINotReadyTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns error for lapi status checks
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("error: lapi not reachable"),
|
|
err: errors.New("lapi unreachable"),
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "started", resp["status"])
|
|
require.False(t, resp["lapi_ready"].(bool))
|
|
require.Contains(t, resp, "warning")
|
|
}
|
|
|
|
// ============================================
|
|
// Additional Coverage Tests
|
|
// ============================================
|
|
|
|
// fakeExecWithError returns an error for executor operations
|
|
type fakeExecWithError struct {
|
|
statusError error
|
|
startError error
|
|
stopError error
|
|
}
|
|
|
|
func (f *fakeExecWithError) Start(ctx context.Context, binPath, configDir string) (int, error) {
|
|
if f.startError != nil {
|
|
return 0, f.startError
|
|
}
|
|
return 12345, nil
|
|
}
|
|
|
|
func (f *fakeExecWithError) Stop(ctx context.Context, configDir string) error {
|
|
return f.stopError
|
|
}
|
|
|
|
func (f *fakeExecWithError) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
|
|
if f.statusError != nil {
|
|
return false, 0, f.statusError
|
|
}
|
|
return true, 12345, nil
|
|
}
|
|
|
|
func TestCrowdsecHandler_Status_Error(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
fe := &fakeExecWithError{statusError: errors.New("status check failed")}
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
require.Contains(t, w.Body.String(), "status check failed")
|
|
}
|
|
|
|
func TestCrowdsecHandler_Start_ExecutorError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
fe := &fakeExecWithError{startError: errors.New("failed to start process")}
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
require.Contains(t, w.Body.String(), "failed to start process")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ExportConfig_DirNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
// Use a non-existent directory
|
|
nonExistentDir := "/tmp/crowdsec-nonexistent-test-" + t.Name()
|
|
_ = os.RemoveAll(nonExistentDir)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", nonExistentDir)
|
|
// Remove any cache dir created during handler init so Export sees missing dir
|
|
_ = os.RemoveAll(nonExistentDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "crowdsec config not found")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ReadFile_NotFound(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=nonexistent.conf", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "not found")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ReadFile_MissingPath(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "path required")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_Success(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns valid JSON decisions
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "192.168.1.1", "duration": "24h", "scenario": "manual ban"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, 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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, float64(1), resp["total"])
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_Empty(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns null (no decisions)
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("null\n"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, float64(0), resp["total"])
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_CscliError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns an error
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("cscli not found"),
|
|
err: errors.New("command failed"),
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Contains(t, w.Body.String(), "cscli not available")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns invalid JSON
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("not valid json"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
require.Contains(t, w.Body.String(), "failed to parse")
|
|
}
|
|
|
|
func TestCrowdsecHandler_BanIP_Success(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision created"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"ip": "192.168.1.100", "duration": "1h", "reason": "test ban"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "banned", resp["status"])
|
|
require.Equal(t, "192.168.1.100", resp["ip"])
|
|
}
|
|
|
|
func TestCrowdsecHandler_BanIP_MissingIP(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"duration": "1h"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "ip is required")
|
|
}
|
|
|
|
func TestCrowdsecHandler_BanIP_EmptyIP(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"ip": " "}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "cannot be empty")
|
|
}
|
|
|
|
func TestCrowdsecHandler_BanIP_DefaultDuration(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision created"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// No duration specified - should default to 24h
|
|
body := `{"ip": "192.168.1.100"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "24h", resp["duration"])
|
|
}
|
|
|
|
func TestCrowdsecHandler_UnbanIP_Success(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision deleted"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "unbanned", resp["status"])
|
|
}
|
|
|
|
func TestCrowdsecHandler_UnbanIP_Error(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("error"),
|
|
err: errors.New("delete failed"),
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
require.Contains(t, w.Body.String(), "failed to unban")
|
|
}
|
|
|
|
// ============================================
|
|
// Additional CrowdSec Handler Tests for Coverage
|
|
// ============================================
|
|
|
|
func TestCrowdsecHandler_BanIP_ExecutionError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("error: failed to add decision"),
|
|
err: errors.New("cscli failed"),
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"ip": "192.168.1.100", "duration": "1h", "reason": "test ban"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
require.Contains(t, w.Body.String(), "failed to ban IP")
|
|
}
|
|
|
|
// Note: TestCrowdsecHandler_Stop_Error is defined in crowdsec_stop_lapi_test.go
|
|
|
|
func TestCrowdsecHandler_CheckLAPIHealth_InvalidURL(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
|
|
// Create config with invalid URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: "default",
|
|
CrowdSecAPIURL: "http://evil.external.com:8080", // Should be blocked by SSRF policy
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
// Close original SecurityService to prevent goroutine leak, then replace with new one
|
|
if h.Security != nil {
|
|
h.Security.Close()
|
|
}
|
|
h.Security = services.NewSecurityService(db)
|
|
t.Cleanup(func() { h.Security.Close() })
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/lapi/health", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.False(t, resp["healthy"].(bool))
|
|
require.Contains(t, resp, "error")
|
|
}
|
|
|
|
func TestCrowdsecHandler_GetLAPIDecisions_Fallback(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that simulates fallback to cscli
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
|
|
// Create config with invalid URL to trigger fallback
|
|
cfg := models.SecurityConfig{
|
|
UUID: "default",
|
|
CrowdSecAPIURL: "http://external.evil.com:8080",
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
// Close original SecurityService to prevent goroutine leak, then replace with new one
|
|
if h.Security != nil {
|
|
h.Security.Close()
|
|
}
|
|
h.Security = services.NewSecurityService(db)
|
|
t.Cleanup(func() { h.Security.Close() })
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/lapi", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should fall back to cscli-based method
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_PullPreset_CerberusDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": "test-slug"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "cerberus disabled")
|
|
}
|
|
|
|
func TestCrowdsecHandler_PullPreset_InvalidPayload(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader("not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "invalid payload")
|
|
}
|
|
|
|
func TestCrowdsecHandler_PullPreset_EmptySlug(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": ""}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "slug required")
|
|
}
|
|
|
|
func TestCrowdsecHandler_PullPreset_HubUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.Hub = nil // Simulate hub unavailable
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": "test-slug"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "hub service unavailable")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ApplyPreset_CerberusDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": "test-slug"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "cerberus disabled")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ApplyPreset_InvalidPayload(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader("not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "invalid payload")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ApplyPreset_EmptySlug(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": " "}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "slug required")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ApplyPreset_HubUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.Hub = nil // Simulate hub unavailable
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"slug": "test-slug"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "hub service unavailable")
|
|
}
|
|
|
|
func TestCrowdsecHandler_UpdateAcquisitionConfig_MissingContent(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "content is required")
|
|
}
|
|
|
|
func TestCrowdsecHandler_UpdateAcquisitionConfig_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader("not-json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_WithConfigYaml(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
// Create config.yaml to trigger the config path code
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, 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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify the -c flag was passed
|
|
require.NotEmpty(t, mockExec.calls)
|
|
foundConfigFlag := false
|
|
for _, call := range mockExec.calls {
|
|
for i, arg := range call.args {
|
|
if arg == "-c" && i+1 < len(call.args) {
|
|
foundConfigFlag = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
require.True(t, foundConfigFlag, "Expected -c flag to be passed when config.yaml exists")
|
|
}
|
|
|
|
func TestCrowdsecHandler_BanIP_WithConfigYaml(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
// Create config.yaml to trigger the config path code
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision created"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"ip": "192.168.1.100"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_UnbanIP_WithConfigYaml(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
// Create config.yaml to trigger the config path code
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision deleted"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, 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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_Status_LAPIReady(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
// Create config.yaml
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("# test config"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
// Mock executor that returns success for LAPI status
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("LAPI OK"),
|
|
err: nil,
|
|
}
|
|
|
|
// fakeExec that reports running
|
|
fe := &fakeExec{started: true}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.True(t, resp["running"].(bool))
|
|
require.True(t, resp["lapi_ready"].(bool))
|
|
}
|
|
|
|
func TestCrowdsecHandler_Status_LAPINotReady(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Mock executor that returns error for LAPI status
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("error: LAPI unavailable"),
|
|
err: errors.New("lapi check failed"),
|
|
}
|
|
|
|
// fakeExec that reports running
|
|
fe := &fakeExec{started: true}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.True(t, resp["running"].(bool))
|
|
require.False(t, resp["lapi_ready"].(bool))
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListDecisions_WithCreatedAt(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Mock executor that returns decisions with created_at field
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"id": 1, "origin": "cscli", "type": "ban", "scope": "ip", "value": "10.0.0.1", "created_at": "2024-01-01T12:00:00Z", "until": "2024-01-02T12:00:00Z"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
decisions := resp["decisions"].([]any)
|
|
require.Len(t, decisions, 1)
|
|
decision := decisions[0].(map[string]any)
|
|
require.Equal(t, "2024-01-02T12:00:00Z", decision["until"])
|
|
}
|
|
|
|
// Note: TestTTLRemainingSeconds, TestMapCrowdsecStatus, TestActorFromContext
|
|
// are defined in crowdsec_handler_comprehensive_test.go
|
|
|
|
func TestCrowdsecHandler_HubEndpoints(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Test with nil Hub
|
|
h := &CrowdsecHandler{Hub: nil}
|
|
endpoints := h.hubEndpoints()
|
|
require.Nil(t, endpoints)
|
|
|
|
// Test with Hub having base URLs
|
|
db := setupCrowdDB(t)
|
|
h2 := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
endpoints2 := h2.hubEndpoints()
|
|
// Hub is initialized with default URLs
|
|
require.NotNil(t, endpoints2)
|
|
}
|
|
|
|
func TestCrowdsecHandler_ConsoleEnroll_ProgressConflict(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
h, _ := setupTestConsoleEnrollment(t)
|
|
|
|
// First enroll to create an "in progress" state
|
|
body := `{"enrollment_key": "abc123456789", "agent_name": "test-agent-1"}`
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Try to enroll again without force - should succeed or conflict based on state
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/console/enroll", strings.NewReader(body))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
// May succeed or return conflict depending on implementation
|
|
require.True(t, w2.Code == http.StatusOK || w2.Code == http.StatusConflict)
|
|
}
|
|
|
|
func TestCrowdsecHandler_GetCachedPreset_CerberusDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/test-slug", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "cerberus disabled")
|
|
}
|
|
|
|
func TestCrowdsecHandler_GetCachedPreset_HubUnavailable(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
// Set Hub to nil to simulate unavailable
|
|
h.Hub = nil
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/test-slug", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
require.Contains(t, w.Body.String(), "unavailable")
|
|
}
|
|
|
|
func TestCrowdsecHandler_GetCachedPreset_EmptySlug(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
db := OpenTestDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Empty slug should result in 404 (route not matched) or 400
|
|
require.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest)
|
|
}
|
|
|
|
// TestCrowdsecHandler_Start_StatusCode tests starting CrowdSec returns 200 status
|
|
func TestCrowdsecHandler_Start_StatusCode(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "started", response["status"])
|
|
}
|
|
|
|
// TestCrowdsecHandler_Stop_UpdatesSecurityConfig tests stopping CrowdSec updates SecurityConfig
|
|
func TestCrowdsecHandler_Stop_UpdatesSecurityConfig(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
fe := &fakeExec{started: true}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", tmpDir)
|
|
|
|
// Create initial SecurityConfig
|
|
cfg := models.SecurityConfig{
|
|
UUID: "default",
|
|
Name: "Default",
|
|
Enabled: true,
|
|
CrowdSecMode: "local",
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify SecurityConfig was updated
|
|
var updatedCfg models.SecurityConfig
|
|
require.NoError(t, db.First(&updatedCfg).Error)
|
|
require.Equal(t, "disabled", updatedCfg.CrowdSecMode)
|
|
require.False(t, updatedCfg.Enabled)
|
|
}
|
|
|
|
// TestCrowdsecHandler_ActorFromContext tests actor extraction from Gin context
|
|
func TestCrowdsecHandler_ActorFromContext(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Test with userID present
|
|
c1, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c1.Set("userID", 123)
|
|
actor1 := actorFromContext(c1)
|
|
require.Equal(t, "user:123", actor1)
|
|
|
|
// Test without userID
|
|
c2, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
actor2 := actorFromContext(c2)
|
|
require.Equal(t, "unknown", actor2)
|
|
}
|
|
|
|
// TestCrowdsecHandler_IsCerberusEnabled_EnvVar tests Cerberus feature flag via environment variable
|
|
func TestCrowdsecHandler_IsCerberusEnabled_EnvVar(t *testing.T) {
|
|
// Note: Cannot use t.Parallel() with t.Setenv in subtests
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
envKey string
|
|
envValue string
|
|
expected bool
|
|
}{
|
|
{"FEATURE_CERBERUS_ENABLED=true", "FEATURE_CERBERUS_ENABLED", "true", true},
|
|
{"FEATURE_CERBERUS_ENABLED=false", "FEATURE_CERBERUS_ENABLED", "false", false},
|
|
{"CERBERUS_ENABLED=1", "CERBERUS_ENABLED", "1", true},
|
|
{"CERBERUS_ENABLED=0", "CERBERUS_ENABLED", "0", false},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv(tc.envKey, tc.envValue)
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
result := h.isCerberusEnabled()
|
|
require.Equal(t, tc.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Phase 1: Added Tests for Coverage Goal
|
|
// ========================================
|
|
|
|
// TestApplyPreset_Success verifies applying aggressive preset and config changes
|
|
// ============================================
|
|
// Phase 1 Additional Coverage Tests
|
|
// ============================================
|
|
|
|
// TestCrowdsecHandler_ApplyPreset_InvalidJSON verifies JSON binding error handling
|
|
func TestCrowdsecHandler_ApplyPreset_InvalidJSON(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader("not valid json{"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "invalid payload")
|
|
}
|
|
|
|
// TestCrowdsecHandler_ApplyPreset_MissingPresetFile verifies cache miss handling
|
|
func TestCrowdsecHandler_ApplyPreset_MissingPresetFile(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
|
|
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Try to apply a preset that was never pulled (cache miss)
|
|
body := `{"slug": "nonexistent-preset"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should return error about missing cache
|
|
require.True(t, w.Code == http.StatusInternalServerError || w.Code == http.StatusGatewayTimeout,
|
|
"Expected 500 or 504 for cache miss, got %d", w.Code)
|
|
require.Contains(t, w.Body.String(), "cache")
|
|
}
|
|
|
|
// TestCrowdsecHandler_GetPresets_DirectoryReadError simulates directory access errors
|
|
func TestCrowdsecHandler_GetPresets_DirectoryReadError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
db := OpenTestDB(t)
|
|
|
|
// Create a cache directory and then make it unreadable
|
|
tmpDir := t.TempDir()
|
|
cacheDir := filepath.Join(tmpDir, "hub_cache")
|
|
require.NoError(t, os.MkdirAll(cacheDir, 0o755)) // #nosec G301 -- test directory
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
// Make cache directory unreadable to trigger error path
|
|
require.NoError(t, os.Chmod(cacheDir, 0o000)) // #nosec G302 -- Intentional test permission
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(cacheDir, 0o755) // #nosec G302 -- Restore permissions for cleanup
|
|
})
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Handler should still return 200 with curated presets even if cache read fails
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.Contains(t, response, "presets")
|
|
}
|
|
|
|
// TestCrowdsecHandler_Start_AlreadyRunning verifies Start when process is already running
|
|
func TestCrowdsecHandler_Start_AlreadyRunning(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create executor that reports process is already running
|
|
fe := &fakeExec{started: true}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Call Status first to verify it's running
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var status map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &status))
|
|
require.True(t, status["running"].(bool), "Process should be reported as running")
|
|
|
|
// Now try to start it again - executor will start it but it's idempotent
|
|
w2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
// Should succeed - fakeExec allows multiple starts
|
|
require.Equal(t, http.StatusOK, w2.Code)
|
|
}
|
|
|
|
// TestCrowdsecHandler_Stop_WhenNotRunning verifies Stop behavior when process isn't running
|
|
func TestCrowdsecHandler_Stop_WhenNotRunning(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
fe := &fakeExec{started: false}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Try to stop when not running - should succeed (idempotent)
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.Equal(t, "stopped", response["status"])
|
|
}
|
|
|
|
// TestCrowdsecHandler_BanIP_InvalidJSON verifies JSON binding for ban requests
|
|
func TestCrowdsecHandler_BanIP_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader("{not valid json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "required")
|
|
}
|
|
|
|
// TestCrowdsecHandler_UnbanIP_MissingParam verifies parameter validation
|
|
func TestCrowdsecHandler_UnbanIP_MissingParam(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Request with empty IP param
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should return 404 (no route match) or 400 (empty param)
|
|
require.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest,
|
|
"Expected 404 or 400 for missing IP param, got %d", w.Code)
|
|
}
|
|
|
|
// TestCrowdsecHandler_ListFiles_WalkError simulates filesystem walk errors
|
|
func TestCrowdsecHandler_ListFiles_WalkError(t *testing.T) {
|
|
// Skip on systems where we can't create permission-denied scenarios
|
|
if os.Getuid() == 0 {
|
|
t.Skip("Skipping permission test when running as root")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
restrictedDir := filepath.Join(tmpDir, "restricted")
|
|
require.NoError(t, os.MkdirAll(restrictedDir, 0o755)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.Chmod(restrictedDir, 0o000)) // #nosec G302 -- Intentional test permission
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(restrictedDir, 0o755) // #nosec G302 -- Restore for cleanup
|
|
})
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Depending on OS behavior, may return 500 or succeed with partial results
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError,
|
|
"Expected 200 or 500 for walk error, got %d", w.Code)
|
|
}
|
|
|
|
// TestCrowdsecHandler_GetCachedPreset_InvalidSlug verifies slug validation
|
|
func TestCrowdsecHandler_GetCachedPreset_InvalidSlug(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Empty slug should be rejected (404 because route requires :slug parameter)
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// TestCrowdsecHandler_GetCachedPreset_CacheMiss verifies cache miss handling
|
|
func TestCrowdsecHandler_GetCachedPreset_CacheMiss(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/nonexistent-slug", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "cache miss")
|
|
}
|
|
|
|
// ============================================
|
|
// PHASE 2: Targeted Coverage Tests (14 functions, ~85% target)
|
|
// ============================================
|
|
|
|
// RegisterBouncer Tests (Target: 20.0% → 75%)
|
|
|
|
func TestCrowdsecHandler_RegisterBouncer_InvalidAPIKey(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create mock executor that returns invalid API key format
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Error: Invalid API key format\n"),
|
|
err: errors.New("exit status 1"),
|
|
}
|
|
|
|
// Create temporary script to test with
|
|
tmpDir := t.TempDir()
|
|
scriptPath := filepath.Join(tmpDir, "register_bouncer.sh")
|
|
scriptContent := `#!/bin/bash
|
|
echo "Error: Invalid API key format"
|
|
exit 1
|
|
`
|
|
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0o755)) // #nosec G306 -- test fixture
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &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.MethodPost, "/api/v1/admin/crowdsec/bouncer/register", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Script doesn't exist at hardcoded path, should return 404
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
require.Contains(t, w.Body.String(), "script not found")
|
|
}
|
|
|
|
func TestCrowdsecHandler_RegisterBouncer_LAPIConnectionError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Error: Cannot connect to LAPI\ncscli lapi status: connection refused\n"),
|
|
err: errors.New("lapi connection failed"),
|
|
}
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/bouncer/register", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Script doesn't exist at hardcoded path, should return 404
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// GetAcquisitionConfig Tests (Target: 40.0% → 75%)
|
|
|
|
func TestCrowdsecHandler_GetAcquisitionConfig_FileNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Handler uses hardcoded path /etc/crowdsec/acquis.yaml
|
|
// In test environment, this file likely doesn't exist
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound,
|
|
"Expected 200 or 404, got %d", w.Code)
|
|
|
|
if w.Code == http.StatusNotFound {
|
|
require.Contains(t, w.Body.String(), "not found")
|
|
}
|
|
}
|
|
|
|
func TestCrowdsecHandler_GetAcquisitionConfig_ParseError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// This test verifies the handler returns content even if YAML is malformed
|
|
// The handler doesn't parse YAML, it just reads the file content
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Handler returns raw file content without parsing, so parse errors don't occur in handler
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusNotFound,
|
|
"Expected 200 or 404, got %d", w.Code)
|
|
}
|
|
|
|
// ImportConfig Tests (Target: 66.7% → 85%)
|
|
|
|
func TestCrowdsecHandler_ImportConfig_InvalidYAML(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Create a file with invalid YAML content - not a valid archive
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
fw, _ := mw.CreateFormFile("file", "invalid.yaml")
|
|
invalidYAML := `this is not: valid: yaml: at: all:`
|
|
_, _ = fw.Write([]byte(invalidYAML))
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should fail validation because it's not a valid archive format
|
|
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
|
require.Contains(t, w.Body.String(), "validation failed")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ImportConfig_ReadError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Test with empty upload (simulates read error)
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
_, _ = mw.CreateFormFile("file", "empty.tgz")
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Empty file should be rejected with validation error
|
|
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
|
require.Contains(t, w.Body.String(), "validation failed")
|
|
}
|
|
|
|
func TestCrowdsecHandler_ImportConfig_MissingRequiredFields(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Test without file parameter
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "file required")
|
|
}
|
|
|
|
// ExportConfig Tests (Target: 73.0% → 90%)
|
|
|
|
func TestCrowdsecHandler_ExportConfig_WriteError(t *testing.T) {
|
|
// Skip on systems where we can't simulate write errors effectively
|
|
if os.Getuid() == 0 {
|
|
t.Skip("Skipping write permission test when running as root")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create data directory with a file
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("test"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Make directory read-only to simulate write error during tar creation
|
|
// Note: This test simulates filesystem-level write errors, but ExportConfig
|
|
// streams directly to HTTP response, so actual write errors are hard to simulate
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should succeed because data dir is readable
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_ExportConfig_PermissionsDenied(t *testing.T) {
|
|
// Skip on systems where we can't simulate permission errors
|
|
if os.Getuid() == 0 {
|
|
t.Skip("Skipping permission test when running as root")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
restrictedFile := filepath.Join(tmpDir, "restricted.conf")
|
|
|
|
// Create file and make it unreadable
|
|
require.NoError(t, os.WriteFile(restrictedFile, []byte("secret"), 0o600)) // #nosec G306 -- test fixture
|
|
require.NoError(t, os.Chmod(restrictedFile, 0o000)) // #nosec G302 -- Intentional test permission
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(restrictedFile, 0o600) // #nosec G302 -- Restore for cleanup
|
|
})
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Export should fail when encountering unreadable files
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError,
|
|
"Expected 200 or 500 for permission error, got %d", w.Code)
|
|
}
|
|
|
|
func TestCrowdsecHandler_ExportConfig_SuccessValidation(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a realistic config structure
|
|
configContent := `# CrowdSec Configuration
|
|
common:
|
|
daemonize: false
|
|
log_level: info
|
|
`
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(configContent), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
// Create nested directory structure
|
|
confDir := filepath.Join(tmpDir, "conf.d")
|
|
require.NoError(t, os.MkdirAll(confDir, 0o750)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.WriteFile(filepath.Join(confDir, "parser.yaml"), []byte("test"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
|
require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
|
|
|
|
// Validate archive contents
|
|
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
|
require.NoError(t, err)
|
|
defer func() { _ = gr.Close() }()
|
|
|
|
tr := tar.NewReader(gr)
|
|
foundConfig := false
|
|
foundParser := false
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
if hdr.Name == "config.yaml" {
|
|
foundConfig = true
|
|
data, _ := io.ReadAll(tr)
|
|
require.Contains(t, string(data), "CrowdSec Configuration")
|
|
}
|
|
if hdr.Name == filepath.Join("conf.d", "parser.yaml") {
|
|
foundParser = true
|
|
}
|
|
}
|
|
|
|
require.True(t, foundConfig, "config.yaml should be in archive")
|
|
require.True(t, foundParser, "conf.d/parser.yaml should be in archive")
|
|
}
|
|
|
|
// ListFiles Tests (Target: 64.7% → 85%)
|
|
|
|
func TestCrowdsecHandler_ListFiles_DirectoryNotExists(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Use explicitly non-existent directory
|
|
nonExistentDir := filepath.Join(os.TempDir(), "crowdsec-test-nonexistent-"+t.Name())
|
|
_ = os.RemoveAll(nonExistentDir) // Ensure it doesn't exist
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", nonExistentDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should return empty list (200) when directory doesn't exist
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
|
|
// Check if files key exists
|
|
require.Contains(t, response, "files", "Response should contain 'files' key")
|
|
|
|
// Safely convert to slice
|
|
filesRaw, ok := response["files"]
|
|
require.True(t, ok, "files key should exist")
|
|
|
|
if filesRaw != nil {
|
|
files, ok := filesRaw.([]any)
|
|
require.True(t, ok, "files should be a slice")
|
|
require.Empty(t, files, "Should return empty list for non-existent directory")
|
|
}
|
|
// If filesRaw is nil, that's also acceptable (empty state)
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListFiles_PermissionDenied(t *testing.T) {
|
|
// Skip on systems where we can't simulate permission errors
|
|
if os.Getuid() == 0 {
|
|
t.Skip("Skipping permission test when running as root")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
restrictedDir := filepath.Join(tmpDir, "restricted")
|
|
require.NoError(t, os.MkdirAll(restrictedDir, 0o755)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.Chmod(restrictedDir, 0o000)) // #nosec G302 -- Intentional test permission
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(restrictedDir, 0o755) // #nosec G302 -- Restore for cleanup
|
|
})
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Walk error should return 500
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError,
|
|
"Expected 200 or 500 for permission error, got %d", w.Code)
|
|
|
|
if w.Code == http.StatusInternalServerError {
|
|
require.Contains(t, w.Body.String(), "error")
|
|
}
|
|
}
|
|
|
|
func TestCrowdsecHandler_ListFiles_FilteringLogic(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create diverse file structure to test filtering
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("config"), 0o600)) // #nosec G306 -- test fixture
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "hidden.txt"), []byte("hidden"), 0o600)) // #nosec G306 -- test fixture
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0o750)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "subdir", "nested.conf"), []byte("nested"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
// Create empty directory (should not appear in files list)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "emptydir"), 0o750)) // #nosec G301 -- test directory
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
|
|
files := response["files"].([]any)
|
|
fileList := make([]string, len(files))
|
|
for i, f := range files {
|
|
fileList[i] = f.(string)
|
|
}
|
|
|
|
// Should include all files but not directories
|
|
require.Contains(t, fileList, "config.yaml")
|
|
require.Contains(t, fileList, "hidden.txt")
|
|
require.Contains(t, fileList, filepath.Join("subdir", "nested.conf"))
|
|
|
|
// Should not include directories themselves
|
|
require.NotContains(t, fileList, "subdir")
|
|
require.NotContains(t, fileList, "emptydir")
|
|
|
|
// Verify file count
|
|
require.Len(t, fileList, 3, "Should return exactly 3 files")
|
|
}
|
|
|
|
// ============================================
|
|
// PHASE 2B: Additional Coverage Boosters (Target: 85%+)
|
|
// ============================================
|
|
|
|
// Test actual file operations to increase ExportConfig coverage
|
|
func TestCrowdsecHandler_ExportConfig_MultipleDirectories(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create complex directory structure
|
|
dirs := []string{
|
|
"parsers",
|
|
"scenarios",
|
|
"collections",
|
|
"postoverflows",
|
|
}
|
|
for _, dir := range dirs {
|
|
dirPath := filepath.Join(tmpDir, dir)
|
|
require.NoError(t, os.MkdirAll(dirPath, 0o750)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.WriteFile(filepath.Join(dirPath, "test.yaml"), []byte("test"), 0o600)) // #nosec G306 -- test fixture
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Validate all directories are in archive
|
|
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
|
require.NoError(t, err)
|
|
defer func() { _ = gr.Close() }()
|
|
|
|
tr := tar.NewReader(gr)
|
|
foundDirs := make(map[string]bool)
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
dir := filepath.Dir(hdr.Name)
|
|
if dir != "." {
|
|
foundDirs[dir] = true
|
|
}
|
|
}
|
|
|
|
// Verify all directories were archived
|
|
for _, dir := range dirs {
|
|
require.True(t, foundDirs[dir], "Directory %s should be in archive", dir)
|
|
}
|
|
}
|
|
|
|
// Test ListFiles with deeply nested structure
|
|
func TestCrowdsecHandler_ListFiles_DeepNesting(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create deeply nested structure
|
|
deepPath := filepath.Join(tmpDir, "a", "b", "c", "d")
|
|
require.NoError(t, os.MkdirAll(deepPath, 0o750)) // #nosec G301 -- test directory
|
|
require.NoError(t, os.WriteFile(filepath.Join(deepPath, "deep.conf"), []byte("deep"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
|
|
files := response["files"].([]any)
|
|
require.Len(t, files, 1)
|
|
require.Equal(t, filepath.Join("a", "b", "c", "d", "deep.conf"), files[0].(string))
|
|
}
|
|
|
|
// Test ImportConfig with actual file operations
|
|
func TestCrowdsecHandler_ImportConfig_LargeFile(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Create a valid large archive with config.yaml
|
|
files := map[string]string{
|
|
"config.yaml": "api:\n server:\n listen_uri: 0.0.0.0:8080\n",
|
|
}
|
|
archivePath := createTestArchive(t, "tar.gz", files, true)
|
|
// #nosec G304 -- archivePath is in test temp directory created by t.TempDir()
|
|
archiveData, err := os.ReadFile(archivePath)
|
|
require.NoError(t, err)
|
|
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
fw, _ := mw.CreateFormFile("file", "large.tar.gz")
|
|
_, _ = fw.Write(archiveData)
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify config.yaml was extracted
|
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
|
_, err = os.Stat(configPath)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Test Start with SecurityConfig creation
|
|
func TestCrowdsecHandler_Start_CreatesSecurityConfig(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
// Ensure no SecurityConfig exists
|
|
var count int64
|
|
db.Model(&models.SecurityConfig{}).Count(&count)
|
|
require.Equal(t, int64(0), count)
|
|
|
|
fe := &fakeExec{}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify SecurityConfig was created
|
|
var cfg models.SecurityConfig
|
|
err := db.First(&cfg).Error
|
|
require.NoError(t, err)
|
|
require.Equal(t, "local", cfg.CrowdSecMode)
|
|
require.True(t, cfg.Enabled)
|
|
}
|
|
|
|
// Test Stop updates existing SecurityConfig
|
|
func TestCrowdsecHandler_Stop_UpdatesExistingConfig(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create pre-existing config
|
|
cfg := models.SecurityConfig{
|
|
UUID: "test-uuid",
|
|
Name: "Test Config",
|
|
Enabled: true,
|
|
CrowdSecMode: "local",
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
fe := &fakeExec{started: true}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/stop", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify config was updated
|
|
var updatedCfg models.SecurityConfig
|
|
require.NoError(t, db.First(&updatedCfg).Error)
|
|
require.Equal(t, "disabled", updatedCfg.CrowdSecMode)
|
|
require.False(t, updatedCfg.Enabled)
|
|
}
|
|
|
|
// Test WriteFile backup creation
|
|
func TestCrowdsecHandler_WriteFile_BackupCreation(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create existing file
|
|
existingFile := filepath.Join(tmpDir, "existing.conf")
|
|
require.NoError(t, os.WriteFile(existingFile, []byte("old content"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := map[string]string{
|
|
"path": "test.conf",
|
|
"content": "new content",
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify backup was created
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Contains(t, resp, "backup")
|
|
|
|
backupPath := resp["backup"].(string)
|
|
require.NotEmpty(t, backupPath)
|
|
|
|
// Verify backup directory exists
|
|
_, err := os.Stat(backupPath)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Test ReadFile with path traversal protection
|
|
func TestCrowdsecHandler_ReadFile_PathTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create file in temp dir
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "safe.conf"), []byte("safe"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Try path traversal attack
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../../etc/passwd", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), "invalid path")
|
|
}
|
|
|
|
// Test Status with config.yaml present
|
|
func TestCrowdsecHandler_Status_WithConfigFile(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create config.yaml
|
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(configPath, []byte("# test config"), 0o600)) // #nosec G306 -- test fixture
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("LAPI OK"),
|
|
err: nil,
|
|
}
|
|
|
|
fe := &fakeExec{started: true}
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, fe, "/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/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.True(t, response["running"].(bool))
|
|
require.True(t, response["lapi_ready"].(bool))
|
|
}
|
|
|
|
// Test BanIP with reason
|
|
func TestCrowdsecHandler_BanIP_WithReason(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("Decision created"),
|
|
err: nil,
|
|
}
|
|
|
|
db := setupCrowdDB(t)
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"ip": "10.0.0.1", "duration": "2h", "reason": "malicious activity detected"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify command was called with correct args
|
|
require.NotEmpty(t, mockExec.calls)
|
|
lastCall := mockExec.calls[len(mockExec.calls)-1]
|
|
require.Contains(t, lastCall.args, "-R")
|
|
require.Contains(t, lastCall.args, "manual ban: malicious activity detected")
|
|
require.Contains(t, lastCall.args, "-d")
|
|
require.Contains(t, lastCall.args, "2h")
|
|
}
|
|
|
|
// Test UpdateAcquisitionConfig creates backup
|
|
func TestCrowdsecHandler_UpdateAcquisitionConfig_CreatesBackup(t *testing.T) {
|
|
// Skip if /etc/crowdsec doesn't exist (not a CrowdSec environment)
|
|
if _, err := os.Stat("/etc/crowdsec"); os.IsNotExist(err) {
|
|
t.Skip("Skipping test: /etc/crowdsec directory does not exist")
|
|
}
|
|
|
|
// Skip if running as non-root (can't write to /etc)
|
|
if os.Getuid() != 0 {
|
|
t.Skip("Skipping test: requires root to write to /etc/crowdsec")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
body := `{"content": "# Updated acquisition config\nsource: file\nfilenames:\n - /var/log/test.log"}`
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
// May succeed or fail depending on permissions
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError,
|
|
"Expected 200 or 500, got %d", w.Code)
|
|
}
|
|
|
|
// ============================================
|
|
// PHASE 2C: Target Low-Coverage Functions (< 80%)
|
|
// ============================================
|
|
|
|
// Test Start when executor.Start fails
|
|
func TestCrowdsecHandler_Start_ExecutorFailure(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
|
|
// Create pre-existing config
|
|
cfg := models.SecurityConfig{
|
|
UUID: "test-uuid",
|
|
Name: "Test Config",
|
|
Enabled: false,
|
|
CrowdSecMode: "disabled",
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
fe := &fakeExec{
|
|
startErr: fmt.Errorf("failed to start process"),
|
|
}
|
|
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
|
|
// Verify config was reverted
|
|
var revertedCfg models.SecurityConfig
|
|
require.NoError(t, db.First(&revertedCfg).Error)
|
|
require.False(t, revertedCfg.Enabled)
|
|
require.Equal(t, "disabled", revertedCfg.CrowdSecMode)
|
|
}
|
|
|
|
// Test Start when LAPI doesn't become ready
|
|
func TestCrowdsecHandler_Start_LAPINotReady(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
|
|
// Mock command executor that always fails LAPI check
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(""),
|
|
err: fmt.Errorf("LAPI not responding"),
|
|
}
|
|
|
|
fe := &fakeExec{started: false}
|
|
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", t.TempDir())
|
|
h.CmdExec = mockExec
|
|
h.LAPIMaxWait = 1 * time.Second // Short timeout for test
|
|
h.LAPIPollInterval = 100 * time.Millisecond
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.Equal(t, "started", response["status"])
|
|
require.False(t, response["lapi_ready"].(bool))
|
|
require.Contains(t, response, "warning")
|
|
}
|
|
|
|
// Test ConsoleStatus when not enrolled
|
|
func TestCrowdsecHandler_ConsoleStatus_NotEnrolled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
|
t.Setenv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT", "true")
|
|
|
|
db := OpenTestDB(t)
|
|
require.NoError(t, db.AutoMigrate(&models.CrowdsecConsoleEnrollment{}))
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte("not enrolled"),
|
|
err: fmt.Errorf("console not configured"),
|
|
}
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
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/console/status", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.Equal(t, "not_enrolled", response["status"])
|
|
}
|
|
|
|
// Test WriteFile with directory creation
|
|
func TestCrowdsecHandler_WriteFile_DirectoryCreation(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Write to a path that requires directory creation
|
|
body := map[string]string{
|
|
"path": "subdir/nested/file.conf",
|
|
"content": "test content",
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify file was created
|
|
fullPath := filepath.Join(tmpDir, "subdir", "nested", "file.conf")
|
|
content, err := os.ReadFile(fullPath) // #nosec G304 -- test file reading from temp dir
|
|
require.NoError(t, err)
|
|
require.Equal(t, "test content", string(content))
|
|
}
|
|
|
|
// Test GetLAPIDecisions with API errors
|
|
func TestCrowdsecHandler_GetLAPIDecisions_APIError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
|
|
// Create SecurityConfig without API key
|
|
cfg := models.SecurityConfig{
|
|
UUID: "test",
|
|
Name: "Test",
|
|
Enabled: true,
|
|
CrowdSecMode: "local",
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should handle missing API key gracefully
|
|
require.True(t, w.Code == http.StatusOK || w.Code == http.StatusInternalServerError)
|
|
}
|
|
|
|
// Test UpdateAcquisitionConfig with read errors
|
|
func TestCrowdsecHandler_UpdateAcquisitionConfig_ReadError(t *testing.T) {
|
|
// Skip if /etc/crowdsec doesn't exist
|
|
if _, err := os.Stat("/etc/crowdsec"); os.IsNotExist(err) {
|
|
t.Skip("Skipping test: /etc/crowdsec directory does not exist")
|
|
}
|
|
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Send invalid JSON
|
|
body := `{"content": "not valid yaml: [[[[[}`
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should fail validation
|
|
require.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusInternalServerError)
|
|
}
|
|
|
|
// Test CheckLAPIHealth with various failure modes
|
|
func TestCrowdsecHandler_CheckLAPIHealth_Timeout(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(""),
|
|
err: context.DeadlineExceeded,
|
|
}
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
|
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/lapi/health", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should return unhealthy status
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
|
require.False(t, response["healthy"].(bool))
|
|
}
|
|
|
|
// Test ExportConfig with write errors
|
|
func TestCrowdsecHandler_ExportConfig_EmptyDirectory(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
tmpDir := t.TempDir()
|
|
// Don't create any subdirectories
|
|
|
|
h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should still succeed but with minimal archive
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
// Test ImportConfig with corrupted archive
|
|
func TestCrowdsecHandler_ImportConfig_CorruptedArchive(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := setupCrowdDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
r := gin.New()
|
|
g := r.Group("/api/v1")
|
|
h.RegisterRoutes(g)
|
|
|
|
// Create corrupted archive (invalid gzip)
|
|
buf := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(buf)
|
|
fw, _ := mw.CreateFormFile("file", "corrupted.tar.gz")
|
|
_, _ = fw.Write([]byte("this is not a valid gzip file"))
|
|
_ = mw.Close()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should fail validation because it's not a valid gzip file
|
|
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
|
require.Contains(t, w.Body.String(), "validation failed")
|
|
}
|
|
|
|
// TestMaskAPIKey tests the maskAPIKey function with various inputs.
|
|
// Security: Ensures API keys are properly masked to prevent log exposure (CWE-312).
|
|
func TestMaskAPIKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "normal key",
|
|
input: "abcd1234567890wxyz",
|
|
expected: "abcd...wxyz",
|
|
},
|
|
{
|
|
name: "empty key",
|
|
input: "",
|
|
expected: "[empty]",
|
|
},
|
|
{
|
|
name: "short key under 16 chars",
|
|
input: "short123",
|
|
expected: "[REDACTED]",
|
|
},
|
|
{
|
|
name: "minimum length key (16 chars)",
|
|
input: "1234567890123456",
|
|
expected: "1234...3456",
|
|
},
|
|
{
|
|
name: "long key",
|
|
input: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
expected: "abcd...WXYZ",
|
|
},
|
|
{
|
|
name: "exactly 15 chars (below minimum)",
|
|
input: "123456789012345",
|
|
expected: "[REDACTED]",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := maskAPIKey(tt.input)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateAPIKeyFormat tests the validateAPIKeyFormat function.
|
|
// Security: Ensures API keys meet minimum security standards.
|
|
func TestValidateAPIKeyFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "valid key with alphanumeric",
|
|
input: "abcd1234567890WXYZ",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "valid key with underscore",
|
|
input: "api_key_1234567890",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "valid key with hyphen",
|
|
input: "api-key-1234567890",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "valid key mixed chars",
|
|
input: "aB3_dE-5_fG7_hI9_jK1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "too short (15 chars)",
|
|
input: "123456789012345",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "minimum valid length (16 chars)",
|
|
input: "1234567890123456",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "maximum valid length (128 chars)",
|
|
input: strings.Repeat("a", 128),
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "too long (129 chars)",
|
|
input: strings.Repeat("a", 129),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "invalid char - space",
|
|
input: "abcd 1234567890wxyz",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "invalid char - special symbols",
|
|
input: "abcd!@#$%^&*()wxyz",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "invalid char - slash",
|
|
input: "abcd/1234567890wxyz",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := validateAPIKeyFormat(tt.input)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLogBouncerKeyBanner_NoSecretExposure verifies that the banner does not expose full API keys.
|
|
// Security: Critical test to prevent API key leakage in logs (CWE-312, CWE-315, CWE-359).
|
|
func TestLogBouncerKeyBanner_NoSecretExposure(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db := OpenTestDB(t)
|
|
tmpDir := t.TempDir()
|
|
h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir)
|
|
|
|
// Test with a realistic API key
|
|
testAPIKey := "test_api_key_123456789_abcdefghijklmnopqrstuvwxyz"
|
|
|
|
// Capture log output (this is a simple test; in production use a proper log capture)
|
|
// For this test, we'll verify the masking function is called correctly
|
|
maskedKey := maskAPIKey(testAPIKey)
|
|
|
|
// Verify the masked key does not contain the full key
|
|
require.NotContains(t, maskedKey, testAPIKey)
|
|
require.Contains(t, maskedKey, "test...")
|
|
require.Contains(t, maskedKey, "...wxyz")
|
|
|
|
// Call the banner function (it will log, but we've verified masking works)
|
|
h.logBouncerKeyBanner(testAPIKey)
|
|
|
|
// If we reach here without panic, the function executed successfully
|
|
// The actual log output would need to be captured in integration tests
|
|
}
|
|
|
|
// TestSaveKeyToFile_SecurePermissions verifies that API keys are saved with secure file permissions.
|
|
// Security: Ensures key files have 0600 permissions to prevent unauthorized access (CWE-732).
|
|
func TestSaveKeyToFile_SecurePermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpDir := t.TempDir()
|
|
keyFile := filepath.Join(tmpDir, "test_bouncer.key")
|
|
testKey := "test_api_key_1234567890_secure"
|
|
|
|
// Save the key
|
|
err := saveKeyToFile(keyFile, testKey)
|
|
require.NoError(t, err)
|
|
|
|
// Verify file exists
|
|
require.FileExists(t, keyFile)
|
|
|
|
// Verify file permissions are 0600
|
|
info, err := os.Stat(keyFile)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0600), info.Mode().Perm(), "File must have 0600 permissions for security")
|
|
|
|
// Verify content is correct
|
|
// #nosec G304 -- keyFile is in test temp directory created by t.TempDir()
|
|
content, err := os.ReadFile(keyFile)
|
|
require.NoError(t, err)
|
|
require.Equal(t, testKey+"\n", string(content))
|
|
}
|
|
|
|
// TestSaveKeyToFile_EmptyKey verifies that empty keys are rejected.
|
|
func TestSaveKeyToFile_RejectEmptyKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpDir := t.TempDir()
|
|
keyFile := filepath.Join(tmpDir, "test_bouncer.key")
|
|
|
|
err := saveKeyToFile(keyFile, "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "cannot save empty key")
|
|
}
|
|
|
|
// TestTestKeyAgainstLAPI_ValidKey verifies that valid API keys are accepted by LAPI.
|
|
func TestTestKeyAgainstLAPI_ValidKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mock LAPI server that returns 200 OK
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, "/v1/decisions/stream", r.URL.Path)
|
|
require.Equal(t, "valid-key-123", r.Header.Get("X-Api-Key"))
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(`{"new": [], "deleted": []}`)); err != nil {
|
|
t.Logf("Warning: failed to write response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
result := handler.testKeyAgainstLAPI(ctx, "valid-key-123")
|
|
|
|
require.True(t, result, "Valid key should return true")
|
|
}
|
|
|
|
// TestTestKeyAgainstLAPI_InvalidKey verifies that invalid API keys are rejected by LAPI.
|
|
func TestTestKeyAgainstLAPI_InvalidKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mock LAPI server that returns 403 Forbidden
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, "/v1/decisions/stream", r.URL.Path)
|
|
require.Equal(t, "invalid-key-456", r.Header.Get("X-Api-Key"))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
if _, err := w.Write([]byte(`{"message": "access forbidden"}`)); err != nil {
|
|
t.Logf("Warning: failed to write response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
result := handler.testKeyAgainstLAPI(ctx, "invalid-key-456")
|
|
|
|
require.False(t, result, "Invalid key should return false immediately (no retries)")
|
|
}
|
|
|
|
// TestTestKeyAgainstLAPI_EmptyKey verifies that empty keys are rejected without making requests.
|
|
func TestTestKeyAgainstLAPI_EmptyKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
ctx := context.Background()
|
|
result := handler.testKeyAgainstLAPI(ctx, "")
|
|
|
|
require.False(t, result, "Empty key should return false without making request")
|
|
}
|
|
|
|
// TestTestKeyAgainstLAPI_Timeout verifies that LAPI requests timeout appropriately.
|
|
func TestTestKeyAgainstLAPI_Timeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mock LAPI server that delays response beyond timeout
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(6 * time.Second) // Exceeds 5s timeout
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
result := handler.testKeyAgainstLAPI(ctx, "test-key")
|
|
|
|
require.False(t, result, "Should return false after timeout")
|
|
}
|
|
|
|
// TestTestKeyAgainstLAPI_NonOKStatus verifies that non-200/403 status codes are handled.
|
|
func TestTestKeyAgainstLAPI_NonOKStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Mock LAPI server that returns 500 Internal Server Error
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
if _, err := w.Write([]byte(`{"error": "internal error"}`)); err != nil {
|
|
t.Logf("Warning: failed to write response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
result := handler.testKeyAgainstLAPI(ctx, "test-key")
|
|
|
|
require.False(t, result, "Should return false for non-OK status")
|
|
}
|
|
|
|
// TestEnsureBouncerRegistration_ValidEnvKey verifies that valid environment keys are used.
|
|
func TestEnsureBouncerRegistration_ValidEnvKey(t *testing.T) {
|
|
// Note: Not parallel - tests share bouncerKeyFile constant
|
|
|
|
// Clean up bouncer key file to ensure test isolation
|
|
if err := os.Remove(bouncerKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove bouncer key file: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Remove(bouncerKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove bouncer key file: %v", err)
|
|
}
|
|
})
|
|
|
|
// Set up environment variable
|
|
if err := os.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "valid-env-key-test"); err != nil {
|
|
t.Fatalf("Failed to set environment variable: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Unsetenv("CHARON_SECURITY_CROWDSEC_API_KEY"); err != nil {
|
|
t.Logf("Warning: failed to unset environment variable: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Mock LAPI server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-Api-Key") == "valid-env-key-test" {
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(`{"new": [], "deleted": []}`)); err != nil {
|
|
t.Logf("Warning: failed to write response: %v", err)
|
|
}
|
|
} else {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
key, err := handler.ensureBouncerRegistration(ctx)
|
|
|
|
require.NoError(t, err)
|
|
require.Empty(t, key, "Should return empty key when using valid env var")
|
|
}
|
|
|
|
// TestEnsureBouncerRegistration_InvalidEnvKeyFallback verifies fallback when env key is invalid.
|
|
func TestEnsureBouncerRegistration_InvalidEnvKeyFallback(t *testing.T) {
|
|
// Note: Not parallel - tests share bouncerKeyFile constant
|
|
|
|
// Clean up bouncer key file to ensure test isolation
|
|
if err := os.Remove(bouncerKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove bouncer key file: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Remove(bouncerKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove bouncer key file: %v", err)
|
|
}
|
|
})
|
|
|
|
// Set up environment variable with invalid key
|
|
if err := os.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "invalid-env-key-test"); err != nil {
|
|
t.Fatalf("Failed to set environment variable: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Unsetenv("CHARON_SECURITY_CROWDSEC_API_KEY"); err != nil {
|
|
t.Logf("Warning: failed to unset environment variable: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Mock LAPI server that rejects all keys
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Mock cscli bouncer registration (using existing MockCommandExecutor)
|
|
mockCmdExec := new(MockCommandExecutor)
|
|
mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool {
|
|
return len(args) >= 2 && args[0] == "bouncers" && args[1] == "delete"
|
|
})).Return([]byte("bouncer deleted"), nil)
|
|
mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool {
|
|
return len(args) >= 2 && args[0] == "bouncers" && args[1] == "add"
|
|
})).Return([]byte("new-generated-key-123"), nil)
|
|
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
handler.CmdExec = mockCmdExec
|
|
|
|
// Create security config with test server URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: uuid.New().String(),
|
|
Name: "default",
|
|
CrowdSecAPIURL: server.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
ctx := context.Background()
|
|
key, err := handler.ensureBouncerRegistration(ctx)
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "new-generated-key-123", key, "Should return newly generated key")
|
|
mockCmdExec.AssertExpectations(t)
|
|
}
|
|
|
|
// TestSaveKeyToFile_AtomicWrite verifies that key files are written atomically.
|
|
func TestSaveKeyToFile_AtomicWrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpDir := t.TempDir()
|
|
keyPath := filepath.Join(tmpDir, "keys", "bouncer_key")
|
|
|
|
// Save key
|
|
err := saveKeyToFile(keyPath, "test-key-123-atomic")
|
|
require.NoError(t, err)
|
|
|
|
// Verify file exists and has correct content
|
|
// #nosec G304 -- keyPath is in test temp directory created by t.TempDir()
|
|
content, err := os.ReadFile(keyPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "test-key-123-atomic\n", string(content))
|
|
|
|
// Verify permissions
|
|
info, err := os.Stat(keyPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
|
|
|
// Verify no temp file left behind
|
|
tmpPath := keyPath + ".tmp"
|
|
_, err = os.Stat(tmpPath)
|
|
require.True(t, os.IsNotExist(err), "Temp file should be removed after atomic write")
|
|
|
|
// Verify directory permissions
|
|
dirInfo, err := os.Stat(filepath.Dir(keyPath))
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0700), dirInfo.Mode().Perm())
|
|
}
|
|
|
|
// TestReadKeyFromFile_Trimming verifies that key file content is properly trimmed.
|
|
func TestReadKeyFromFile_Trimming(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Key with newline",
|
|
content: "test-key-123\n",
|
|
expected: "test-key-123",
|
|
},
|
|
{
|
|
name: "Key with extra whitespace",
|
|
content: " test-key-456 \n",
|
|
expected: "test-key-456",
|
|
},
|
|
{
|
|
name: "Key without newline",
|
|
content: "test-key-789",
|
|
expected: "test-key-789",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
keyPath := filepath.Join(tmpDir, strings.ReplaceAll(tt.name, " ", "_"))
|
|
err := os.WriteFile(keyPath, []byte(tt.content), 0600)
|
|
require.NoError(t, err)
|
|
|
|
result := readKeyFromFile(keyPath)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
|
|
// Test non-existent file
|
|
result := readKeyFromFile(filepath.Join(tmpDir, "nonexistent"))
|
|
require.Empty(t, result, "Should return empty string for non-existent file")
|
|
}
|
|
|
|
// TestGetBouncerAPIKeyFromEnv_Priority verifies environment variable priority order.
|
|
func TestGetBouncerAPIKeyFromEnv_Priority(t *testing.T) {
|
|
// Not parallel: this test mutates process environment
|
|
t.Setenv("CROWDSEC_API_KEY", "")
|
|
t.Setenv("CROWDSEC_BOUNCER_API_KEY", "")
|
|
t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "")
|
|
t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "")
|
|
t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "")
|
|
|
|
// Test priority order (first match wins)
|
|
t.Setenv("CROWDSEC_API_KEY", "key1")
|
|
|
|
result := getBouncerAPIKeyFromEnv()
|
|
require.Equal(t, "key1", result)
|
|
|
|
// Clear first and test second priority
|
|
t.Setenv("CROWDSEC_API_KEY", "")
|
|
t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "key2")
|
|
|
|
result = getBouncerAPIKeyFromEnv()
|
|
require.Equal(t, "key2", result)
|
|
|
|
// Test empty result when no env vars set
|
|
t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "")
|
|
result = getBouncerAPIKeyFromEnv()
|
|
require.Empty(t, result, "Should return empty string when no env vars set")
|
|
}
|
|
|
|
// TestEnsureBouncerRegistration_ConcurrentCalls verifies that concurrent registration
|
|
// attempts are protected by mutex and only ONE bouncer registration occurs.
|
|
func TestEnsureBouncerRegistration_ConcurrentCalls(t *testing.T) {
|
|
// Note: Not parallel - tests share bouncerKeyFile constant
|
|
|
|
// Clean up bouncer key file before and after test to ensure isolation
|
|
testKeyFile := bouncerKeyFile
|
|
if err := os.Remove(testKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove test key file: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Remove(testKeyFile); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("Warning: failed to remove test key file: %v", err)
|
|
}
|
|
})
|
|
|
|
// Clear environment variables to force registration
|
|
envVars := []string{
|
|
"CROWDSEC_API_KEY",
|
|
"CHARON_SECURITY_CROWDSEC_API_KEY",
|
|
}
|
|
for _, key := range envVars {
|
|
if err := os.Unsetenv(key); err != nil {
|
|
t.Logf("Warning: failed to unset %s: %v", key, err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, key := range envVars {
|
|
if err := os.Unsetenv(key); err != nil {
|
|
t.Logf("Warning: failed to unset %s: %v", key, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Track valid keys after registration
|
|
var validKeyMutex sync.Mutex
|
|
validKeys := make(map[string]bool)
|
|
|
|
// Mock LAPI server that accepts keys added by registration
|
|
lapiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
apiKey := r.Header.Get("X-Api-Key")
|
|
|
|
validKeyMutex.Lock()
|
|
isValid := validKeys[apiKey]
|
|
validKeyMutex.Unlock()
|
|
|
|
if isValid {
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(`{"new": [], "deleted": []}`)); err != nil {
|
|
t.Logf("Warning: failed to write response: %v", err)
|
|
}
|
|
} else {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}
|
|
}))
|
|
defer lapiServer.Close()
|
|
|
|
// Thread-safe mock command executor that tracks calls
|
|
type commandCall struct {
|
|
cmd string
|
|
args []string
|
|
}
|
|
var (
|
|
callsMutex sync.Mutex
|
|
calls []commandCall
|
|
)
|
|
|
|
mockCmdExec := new(MockCommandExecutor)
|
|
|
|
// Mock bouncer delete (may be called multiple times, but registration should be once)
|
|
mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool {
|
|
matches := len(args) >= 2 && args[0] == "bouncers" && args[1] == "delete"
|
|
if matches {
|
|
callsMutex.Lock()
|
|
calls = append(calls, commandCall{cmd: "cscli", args: args})
|
|
callsMutex.Unlock()
|
|
}
|
|
return matches
|
|
})).Return([]byte("bouncer deleted"), nil)
|
|
|
|
// Mock bouncer add (should be called EXACTLY ONCE)
|
|
addCallCount := 0
|
|
var addMutex sync.Mutex
|
|
mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool {
|
|
matches := len(args) >= 2 && args[0] == "bouncers" && args[1] == "add"
|
|
if matches {
|
|
addMutex.Lock()
|
|
addCallCount++
|
|
addMutex.Unlock()
|
|
|
|
callsMutex.Lock()
|
|
calls = append(calls, commandCall{cmd: "cscli", args: args})
|
|
callsMutex.Unlock()
|
|
|
|
// Mark the generated key as valid for LAPI authentication
|
|
validKeyMutex.Lock()
|
|
validKeys["test-concurrent-key-123"] = true
|
|
validKeyMutex.Unlock()
|
|
}
|
|
return matches
|
|
})).Return([]byte("test-concurrent-key-123"), nil)
|
|
|
|
// Setup handler with test database
|
|
db := setupCrowdDB(t)
|
|
handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", t.TempDir())
|
|
handler.CmdExec = mockCmdExec
|
|
|
|
// Create security config with mock LAPI URL
|
|
cfg := models.SecurityConfig{
|
|
UUID: "test-uuid",
|
|
Name: "default",
|
|
CrowdSecAPIURL: lapiServer.URL,
|
|
}
|
|
require.NoError(t, db.Create(&cfg).Error)
|
|
|
|
// Override bouncerKeyFile for this test (normally a const)
|
|
// We'll verify by reading from the temp file after registration
|
|
originalBouncerKeyFile := bouncerKeyFile
|
|
t.Cleanup(func() {
|
|
// Restore original (though it's a const, this is to satisfy linters)
|
|
_ = originalBouncerKeyFile
|
|
})
|
|
|
|
// Execute: Launch 10 concurrent ensureBouncerRegistration() calls
|
|
const concurrency = 10
|
|
var wg sync.WaitGroup
|
|
errorsCh := make(chan error, concurrency)
|
|
keysCh := make(chan string, concurrency)
|
|
|
|
for i := 0; i < concurrency; i++ {
|
|
wg.Add(1)
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
key, err := handler.ensureBouncerRegistration(context.Background())
|
|
errorsCh <- err
|
|
keysCh <- key
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errorsCh)
|
|
close(keysCh)
|
|
|
|
// Verify: All calls succeeded
|
|
errorCount := 0
|
|
for err := range errorsCh {
|
|
if err != nil {
|
|
t.Errorf("ensureBouncerRegistration failed: %v", err)
|
|
errorCount++
|
|
}
|
|
}
|
|
require.Equal(t, 0, errorCount, "All concurrent calls should succeed")
|
|
|
|
// Verify: All keys are either empty (cached) or the same generated key
|
|
var seenKeys []string
|
|
for key := range keysCh {
|
|
if key != "" { // Non-empty means a new registration occurred
|
|
seenKeys = append(seenKeys, key)
|
|
}
|
|
}
|
|
|
|
// Verify: Only ONE cscli bouncer add command was called
|
|
addMutex.Lock()
|
|
finalAddCount := addCallCount
|
|
addMutex.Unlock()
|
|
|
|
assert.Equal(t, 1, finalAddCount, "Bouncer registration should be called exactly once")
|
|
|
|
// Verify: The generated key is consistent
|
|
if len(seenKeys) > 0 {
|
|
for _, key := range seenKeys {
|
|
assert.Equal(t, "test-concurrent-key-123", key, "All returned keys should match")
|
|
}
|
|
}
|
|
|
|
mockCmdExec.AssertExpectations(t)
|
|
}
|
|
|
|
func TestValidateBouncerKey_BouncerExists(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"name":"caddy-bouncer"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.True(t, result)
|
|
require.Len(t, mockExec.calls, 1)
|
|
assert.Equal(t, "cscli", mockExec.calls[0].name)
|
|
}
|
|
|
|
func TestValidateBouncerKey_BouncerNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"name":"some-other-bouncer"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestValidateBouncerKey_EmptyOutput(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(``),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestValidateBouncerKey_NullOutput(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`null`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestValidateBouncerKey_CmdError(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: nil,
|
|
err: errors.New("command failed"),
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestValidateBouncerKey_InvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`not valid json`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := h.validateBouncerKey(ctx)
|
|
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestGetBouncerInfo_FromEnvVar(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CROWDSEC_API_KEY", "test-api-key-12345678901234567890")
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"name":"caddy-bouncer"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer", nil)
|
|
|
|
h.GetBouncerInfo(c)
|
|
|
|
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, "env_var", resp["key_source"])
|
|
assert.Equal(t, "caddy-bouncer", resp["name"])
|
|
assert.True(t, resp["registered"].(bool))
|
|
}
|
|
|
|
func TestGetBouncerInfo_NotRegistered(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CROWDSEC_API_KEY", "test-api-key-12345678901234567890")
|
|
|
|
mockExec := &mockCmdExecutor{
|
|
output: []byte(`[{"name":"other-bouncer"}]`),
|
|
err: nil,
|
|
}
|
|
|
|
h := &CrowdsecHandler{
|
|
CmdExec: mockExec,
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer", nil)
|
|
|
|
h.GetBouncerInfo(c)
|
|
|
|
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, "env_var", resp["key_source"])
|
|
assert.False(t, resp["registered"].(bool))
|
|
}
|
|
|
|
func TestGetBouncerKey_FromEnvVar(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CROWDSEC_API_KEY", "test-env-key-value-12345")
|
|
|
|
h := &CrowdsecHandler{}
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer/key", nil)
|
|
|
|
h.GetBouncerKey(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]string
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test-env-key-value-12345", resp["key"])
|
|
assert.Equal(t, "env_var", resp["source"])
|
|
}
|
|
|
|
func TestGetKeyStatus_EnvKeyValid(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CROWDSEC_API_KEY", "test-api-key-12345678901234567890")
|
|
|
|
h := &CrowdsecHandler{}
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/key-status", nil)
|
|
|
|
h.GetKeyStatus(c)
|
|
|
|
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, "env", resp["key_source"])
|
|
assert.Contains(t, resp["current_key_preview"].(string), "...")
|
|
}
|
|
|
|
func TestGetKeyStatus_EnvKeyRejected(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("CROWDSEC_API_KEY", "rejected-key-123456789012345")
|
|
|
|
h := &CrowdsecHandler{
|
|
envKeyRejected: true,
|
|
rejectedEnvKey: "rejected-key-123456789012345",
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/key-status", nil)
|
|
|
|
h.GetKeyStatus(c)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
assert.True(t, resp["env_key_rejected"].(bool))
|
|
assert.Contains(t, resp["message"].(string), "CHARON_SECURITY_CROWDSEC_API_KEY")
|
|
}
|