Ver código fonte

Revamp unit tests (#1368)

* Revamp unit tests
* Increase coverage
* Use go-acc to get cross packages coverage

Signed-off-by: Shivam Sandbhor <shivam.sandbhor@gmail.com>
Thibault "bui" Koechlin 3 anos atrás
pai
commit
d8dc01cd94

+ 2 - 4
.github/workflows/ci_go-test.yml

@@ -62,11 +62,9 @@ jobs:
     - name: Check out code into the Go module directory
       uses: actions/checkout@v2
     - name: Build
-      run: make build && go get -u github.com/jandelgado/gcov2lcov
-    - name: Build package
-      run: make package
+      run: make build && go get -u github.com/jandelgado/gcov2lcov && go get -u github.com/ory/go-acc
     - name: All tests
-      run: go test -coverprofile=coverage.out -covermode=atomic ./...
+      run: go run github.com/ory/go-acc ./... -o coverage.out --ignore database,notifications,protobufs,cwversion,cstest,models
     - name: gcov2lcov
       uses: jandelgado/gcov2lcov-action@v1.0.2
       with:

+ 2 - 16
cmd/crowdsec-cli/explain.go

@@ -1,13 +1,13 @@
 package main
 
 import (
-	"bufio"
 	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
 
 	"github.com/crowdsecurity/crowdsec/pkg/cstest"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -63,7 +63,7 @@ cscli explain --dsn "file://myfile.log" --type nginx
 					log.Fatalf("unable to get absolue path of '%s', exiting", logFile)
 				}
 				dsn = fmt.Sprintf("file://%s", absolutePath)
-				lineCount := getLineCountForFile(absolutePath)
+				lineCount := types.GetLineCountForFile(absolutePath)
 				if lineCount > 100 {
 					log.Warnf("log file contains %d lines. This may take lot of resources.", lineCount)
 				}
@@ -112,17 +112,3 @@ cscli explain --dsn "file://myfile.log" --type nginx
 
 	return cmdExplain
 }
-
-func getLineCountForFile(filepath string) int {
-	f, err := os.Open(filepath)
-	if err != nil {
-		log.Fatalf("unable to open log file %s", filepath)
-	}
-	defer f.Close()
-	lc := 0
-	fs := bufio.NewScanner(f)
-	for fs.Scan() {
-		lc++
-	}
-	return lc
-}

+ 2 - 1
go.mod

@@ -38,6 +38,7 @@ require (
 	github.com/hashicorp/go-version v1.2.1
 	github.com/influxdata/go-syslog/v3 v3.0.0
 	github.com/jackc/pgx/v4 v4.14.1
+	github.com/jarcoal/httpmock v1.1.0
 	github.com/jszwec/csvutil v1.5.1
 	github.com/lib/pq v1.10.4
 	github.com/mattn/go-sqlite3 v1.14.10
@@ -148,7 +149,7 @@ require (
 	go.mongodb.org/mongo-driver v1.4.4 // indirect
 	golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
-	golang.org/x/sys v0.0.0-20220317022123-2c4bbad7e934 // indirect
+	golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 // indirect
 	golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
 	golang.org/x/text v0.3.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect

+ 4 - 2
go.sum

@@ -610,6 +610,8 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f
 github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jarcoal/httpmock v1.1.0 h1:F47ChZj1Y2zFsCXxNkBPwNNKnAyOATcdQibk0qEdVCE=
+github.com/jarcoal/httpmock v1.1.0/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
 github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
 github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74=
@@ -1253,8 +1255,8 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220317022123-2c4bbad7e934 h1:GwUTNnIS5asZGjc34dMBLO/LLp4kEvyZr/8wlQs1Bt8=
-golang.org/x/sys v0.0.0-20220317022123-2c4bbad7e934/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 h1:saXMvIOKvRFwbOMicHXr0B1uwoxq9dGmLe5ExMES6c4=
+golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=

+ 4 - 1
pkg/apiclient/client.go

@@ -63,8 +63,11 @@ func NewClient(config *Config) (*ApiClient, error) {
 func NewDefaultClient(URL *url.URL, prefix string, userAgent string, client *http.Client) (*ApiClient, error) {
 	if client == nil {
 		client = &http.Client{}
+		if ht, ok := http.DefaultTransport.(*http.Transport); ok {
+			ht.TLSClientConfig = &tls.Config{InsecureSkipVerify: InsecureSkipVerify}
+			client.Transport = ht
+		}
 	}
-	http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: InsecureSkipVerify}
 	c := &ApiClient{client: client, BaseURL: URL, UserAgent: userAgent, URLPrefix: prefix}
 	c.common.client = c
 	c.Decisions = (*DecisionsService)(&c.common)

+ 144 - 297
pkg/apiserver/alerts_test.go

@@ -3,13 +3,11 @@ package apiserver
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"strings"
 	"sync"
 	"testing"
-	"time"
 
 	"github.com/crowdsecurity/crowdsec/pkg/csplugin"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
@@ -19,6 +17,48 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+type LAPI struct {
+	router     *gin.Engine
+	loginResp  models.WatcherAuthResponse
+	bouncerKey string
+	t          *testing.T
+}
+
+func SetupLAPITest(t *testing.T) LAPI {
+	t.Helper()
+	router, loginResp, err := InitMachineTest()
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	APIKey, err := CreateTestBouncer()
+	if err != nil {
+		t.Fatalf("%s", err.Error())
+	}
+	return LAPI{
+		router:     router,
+		loginResp:  loginResp,
+		bouncerKey: APIKey,
+	}
+}
+
+func (l *LAPI) InsertAlertFromFile(path string) *httptest.ResponseRecorder {
+	alertReader := GetAlertReaderFromFile(path)
+	return l.RecordResponse("POST", "/v1/alerts", alertReader)
+}
+
+func (l *LAPI) RecordResponse(verb string, url string, body *strings.Reader) *httptest.ResponseRecorder {
+	w := httptest.NewRecorder()
+	req, err := http.NewRequest(verb, url, body)
+	if err != nil {
+		l.t.Fatal(err)
+	}
+	req.Header.Add("X-Api-Key", l.bouncerKey)
+	AddAuthHeaders(req, l.loginResp)
+	l.router.ServeHTTP(w, req)
+	return w
+}
+
 func InitMachineTest() (*gin.Engine, models.WatcherAuthResponse, error) {
 	router, err := NewAPITest()
 	if err != nil {
@@ -61,82 +101,40 @@ func AddAuthHeaders(request *http.Request, authResponse models.WatcherAuthRespon
 }
 
 func TestSimulatedAlert(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_minibulk+simul.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	lapi := SetupLAPITest(t)
+	lapi.InsertAlertFromFile("./tests/alert_minibulk+simul.json")
+	alertContent := GetAlertReaderFromFile("./tests/alert_minibulk+simul.json")
 	//exclude decision in simulation mode
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?simulated=false", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w := lapi.RecordResponse("GET", "/v1/alerts?simulated=false", alertContent)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), `"message":"Ip 91.121.79.178 performed crowdsecurity/ssh-bf (6 events over `)
 	assert.NotContains(t, w.Body.String(), `"message":"Ip 91.121.79.179 performed crowdsecurity/ssh-bf (6 events over `)
 	//include decision in simulation mode
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?simulated=true", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?simulated=true", alertContent)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), `"message":"Ip 91.121.79.178 performed crowdsecurity/ssh-bf (6 events over `)
 	assert.Contains(t, w.Body.String(), `"message":"Ip 91.121.79.179 performed crowdsecurity/ssh-bf (6 events over `)
 }
 
 func TestCreateAlert(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
+	lapi := SetupLAPITest(t)
 	// Create Alert with invalid format
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader("test"))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
 
+	w := lapi.RecordResponse("POST", "/v1/alerts", strings.NewReader("test"))
 	assert.Equal(t, 400, w.Code)
 	assert.Equal(t, "{\"message\":\"invalid character 'e' in literal true (expecting 'r')\"}", w.Body.String())
 
 	// Create Alert with invalid input
-	alertContentBytes, err := ioutil.ReadFile("./tests/invalidAlert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
-
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	alertContent := GetAlertReaderFromFile("./tests/invalidAlert_sample.json")
 
+	w = lapi.RecordResponse("POST", "/v1/alerts", alertContent)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, "{\"message\":\"validation failure list:\\n0.scenario in body is required\\n0.scenario_hash in body is required\\n0.scenario_version in body is required\\n0.simulated in body is required\\n0.source in body is required\"}", w.Body.String())
 
 	// Create Valid Alert
-	alertContentBytes, err = ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent = string(alertContentBytes)
-
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	w = lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assert.Equal(t, 201, w.Code)
 	assert.Equal(t, "[\"1\"]", w.Body.String())
 }
@@ -154,12 +152,7 @@ func TestCreateAlertChannels(t *testing.T) {
 	if err != nil {
 		log.Fatalln(err.Error())
 	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_ssh-bf.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
+	lapi := LAPI{router: apiServer.router, loginResp: loginResp}
 
 	var pd csplugin.ProfileAlert
 	var wg sync.WaitGroup
@@ -170,389 +163,248 @@ func TestCreateAlertChannels(t *testing.T) {
 		wg.Done()
 	}()
 
-	go func() {
-		for {
-			w := httptest.NewRecorder()
-			req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-			AddAuthHeaders(req, loginResp)
-			apiServer.controller.Router.ServeHTTP(w, req)
-			break
-		}
-	}()
+	go lapi.InsertAlertFromFile("./tests/alert_ssh-bf.json")
 	wg.Wait()
 	assert.Equal(t, len(pd.Alert.Decisions), 1)
 	apiServer.Close()
 }
 
 func TestAlertListFilters(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_ssh-bf.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	//create one alert
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi := SetupLAPITest(t)
+	lapi.InsertAlertFromFile("./tests/alert_ssh-bf.json")
+	alertContent := GetAlertReaderFromFile("./tests/alert_ssh-bf.json")
 
 	//bad filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?test=test", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w := lapi.RecordResponse("GET", "/v1/alerts?test=test", alertContent)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, "{\"message\":\"Filter parameter 'test' is unknown (=test): invalid filter\"}", w.Body.String())
 
 	//get without filters
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	//check alert and decision
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test decision_type filter (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?decision_type=ban", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?decision_type=ban", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test decision_type filter (bad value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?decision_type=ratata", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?decision_type=ratata", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test scope (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?scope=Ip", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?scope=Ip", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test scope (bad value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?scope=rarara", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?scope=rarara", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test scenario (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?scenario=crowdsecurity/ssh-bf", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?scenario=crowdsecurity/ssh-bf", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test scenario (bad value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?scenario=crowdsecurity/nope", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?scenario=crowdsecurity/nope", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test ip (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?ip=91.121.79.195", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?ip=91.121.79.195", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test ip (bad value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?ip=99.122.77.195", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?ip=99.122.77.195", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test ip (invalid value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?ip=gruueq", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?ip=gruueq", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, `{"message":"unable to convert 'gruueq' to int: invalid address: invalid ip address / range"}`, w.Body.String())
 
 	//test range (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?range=91.121.79.0/24&contains=false", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?range=91.121.79.0/24&contains=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test range
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?range=99.122.77.0/24&contains=false", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?range=99.122.77.0/24&contains=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test range (invalid value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?range=ratata", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?range=ratata", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, `{"message":"unable to convert 'ratata' to int: invalid address: invalid ip address / range"}`, w.Body.String())
 
 	//test since (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?since=1h", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?since=1h", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test since (ok but yelds no results)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?since=1ns", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?since=1ns", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test since (invalid value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?since=1zuzu", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?since=1zuzu", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Contains(t, w.Body.String(), `{"message":"while parsing duration: time: unknown unit`)
 
 	//test until (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?until=1ns", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?until=1ns", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test until (ok but no return)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?until=1m", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?until=1m", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test until (invalid value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?until=1zuzu", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?until=1zuzu", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Contains(t, w.Body.String(), `{"message":"while parsing duration: time: unknown unit`)
 
 	//test simulated (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?simulated=true", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?simulated=true", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test simulated (ok)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?simulated=false", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?simulated=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test has active decision
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?has_active_decision=true", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?has_active_decision=true", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "Ip 91.121.79.195 performed 'crowdsecurity/ssh-bf' (6 events over ")
 	assert.Contains(t, w.Body.String(), `scope":"Ip","simulated":false,"type":"ban","value":"91.121.79.195"`)
 
 	//test has active decision
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?has_active_decision=false", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?has_active_decision=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, "null", w.Body.String())
 
 	//test has active decision (invalid value)
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?has_active_decision=ratatqata", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/alerts?has_active_decision=ratatqata", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, `{"message":"'ratatqata' is not a boolean: strconv.ParseBool: parsing \"ratatqata\": invalid syntax: unable to parse type"}`, w.Body.String())
 
 }
 
 func TestAlertBulkInsert(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
+	lapi := SetupLAPITest(t)
 	//insert a bulk of 20 alerts to trigger bulk insert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_bulk.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
+	lapi.InsertAlertFromFile("./tests/alert_bulk.json")
+	alertContent := GetAlertReaderFromFile("./tests/alert_bulk.json")
 
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	w := lapi.RecordResponse("GET", "/v1/alerts", alertContent)
 	assert.Equal(t, 200, w.Code)
 }
 
 func TestListAlert(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	lapi := SetupLAPITest(t)
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	// List Alert with invalid filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts?test=test", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w := lapi.RecordResponse("GET", "/v1/alerts?test=test", emptyBody)
 	assert.Equal(t, 500, w.Code)
 	assert.Equal(t, "{\"message\":\"Filter parameter 'test' is unknown (=test): invalid filter\"}", w.Body.String())
 
 	// List Alert
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/alerts", nil)
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
 
+	w = lapi.RecordResponse("GET", "/v1/alerts", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Contains(t, w.Body.String(), "crowdsecurity/test")
 }
 
 func TestCreateAlertErrors(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
+	lapi := SetupLAPITest(t)
+	alertContent := GetAlertReaderFromFile("./tests/alert_sample.json")
 
 	//test invalid bearer
 	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
+	req, _ := http.NewRequest("POST", "/v1/alerts", alertContent)
 	req.Header.Add("User-Agent", UserAgent)
 	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "ratata"))
-	router.ServeHTTP(w, req)
+	lapi.router.ServeHTTP(w, req)
 	assert.Equal(t, 401, w.Code)
 
 	//test invalid bearer
 	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
+	req, _ = http.NewRequest("POST", "/v1/alerts", alertContent)
 	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", loginResp.Token+"s"))
-	router.ServeHTTP(w, req)
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", lapi.loginResp.Token+"s"))
+	lapi.router.ServeHTTP(w, req)
 	assert.Equal(t, 401, w.Code)
 
 }
 
 func TestDeleteAlert(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alertContent := string(alertContentBytes)
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi := SetupLAPITest(t)
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 
 	// Fail Delete Alert
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/alerts", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
+	w := httptest.NewRecorder()
+	req, _ := http.NewRequest("DELETE", "/v1/alerts", strings.NewReader(""))
+	AddAuthHeaders(req, lapi.loginResp)
 	req.RemoteAddr = "127.0.0.2:4242"
-	router.ServeHTTP(w, req)
-
+	lapi.router.ServeHTTP(w, req)
 	assert.Equal(t, 403, w.Code)
 	assert.Equal(t, `{"message":"access forbidden from this IP (127.0.0.2)"}`, w.Body.String())
 
 	// Delete Alert
 	w = httptest.NewRecorder()
 	req, _ = http.NewRequest("DELETE", "/v1/alerts", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
+	AddAuthHeaders(req, lapi.loginResp)
 	req.RemoteAddr = "127.0.0.1:4242"
-	router.ServeHTTP(w, req)
+	lapi.router.ServeHTTP(w, req)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"1"}`, w.Body.String())
 }
@@ -579,17 +431,10 @@ func TestDeleteAlertTrustedIPS(t *testing.T) {
 	if err != nil {
 		log.Fatal(err.Error())
 	}
-
-	insertAlert := func() {
-		alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-		if err != nil {
-			log.Fatal(err)
-		}
-		alertContent := string(alertContentBytes)
-		w := httptest.NewRecorder()
-		req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(alertContent))
-		AddAuthHeaders(req, loginResp)
-		router.ServeHTTP(w, req)
+	lapi := LAPI{
+		router:    router,
+		loginResp: loginResp,
+		t:         t,
 	}
 
 	assertAlertDeleteFailedFromIP := func(ip string) {
@@ -598,6 +443,7 @@ func TestDeleteAlertTrustedIPS(t *testing.T) {
 
 		AddAuthHeaders(req, loginResp)
 		req.RemoteAddr = ip + ":1234"
+
 		router.ServeHTTP(w, req)
 		assert.Equal(t, 403, w.Code)
 		assert.Contains(t, w.Body.String(), fmt.Sprintf(`{"message":"access forbidden from this IP (%s)"}`, ip))
@@ -608,23 +454,24 @@ func TestDeleteAlertTrustedIPS(t *testing.T) {
 		req, _ := http.NewRequest("DELETE", "/v1/alerts", strings.NewReader(""))
 		AddAuthHeaders(req, loginResp)
 		req.RemoteAddr = ip + ":1234"
+
 		router.ServeHTTP(w, req)
 		assert.Equal(t, 200, w.Code)
 		assert.Equal(t, `{"nbDeleted":"1"}`, w.Body.String())
 	}
 
-	insertAlert()
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assertAlertDeleteFailedFromIP("4.3.2.1")
 	assertAlertDeletedFromIP("1.2.3.4")
 
-	insertAlert()
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assertAlertDeletedFromIP("1.2.4.0")
-	insertAlert()
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assertAlertDeletedFromIP("1.2.4.1")
-	insertAlert()
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assertAlertDeletedFromIP("1.2.4.255")
 
-	insertAlert()
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 	assertAlertDeletedFromIP("127.0.0.1")
 
 }

+ 200 - 175
pkg/apiserver/apic.go

@@ -24,12 +24,16 @@ import (
 	"gopkg.in/tomb.v2"
 )
 
-const (
-	PullInterval    = "2h"
-	PushInterval    = "30s"
-	MetricsInterval = "30m"
+var (
+	PullInterval    = time.Hour * 2
+	PushInterval    = time.Second * 30
+	MetricsInterval = time.Minute * 30
 )
 
+var SCOPE_CAPI string = "CAPI"
+var SCOPE_CAPI_ALIAS string = "crowdsecurity/community-blocklist" //we don't use "CAPI" directly, to make it less confusing for the user
+var SCOPE_LISTS string = "lists"
+
 type apic struct {
 	pullInterval    time.Duration
 	pushInterval    time.Duration
@@ -47,15 +51,6 @@ type apic struct {
 	consoleConfig   *csconfig.ConsoleConfig
 }
 
-func IsInSlice(a string, b []string) bool {
-	for _, v := range b {
-		if a == v {
-			return true
-		}
-	}
-	return false
-}
-
 func (a *apic) FetchScenariosListFromDB() ([]string, error) {
 	scenarios := make([]string, 0)
 	machines, err := a.dbClient.ListMachines()
@@ -67,7 +62,7 @@ func (a *apic) FetchScenariosListFromDB() ([]string, error) {
 		machineScenarios := strings.Split(v.Scenarios, ",")
 		log.Debugf("%d scenarios for machine %d", len(machineScenarios), v.ID)
 		for _, sv := range machineScenarios {
-			if !IsInSlice(sv, scenarios) && sv != "" {
+			if !types.InSlice(sv, scenarios) && sv != "" {
 				scenarios = append(scenarios, sv)
 			}
 		}
@@ -76,7 +71,7 @@ func (a *apic) FetchScenariosListFromDB() ([]string, error) {
 	return scenarios, nil
 }
 
-func AlertToSignal(alert *models.Alert, scenarioTrust string) *models.AddSignalsRequestItem {
+func alertToSignal(alert *models.Alert, scenarioTrust string) *models.AddSignalsRequestItem {
 	return &models.AddSignalsRequestItem{
 		Message:         alert.Message,
 		Scenario:        alert.Scenario,
@@ -94,29 +89,19 @@ func AlertToSignal(alert *models.Alert, scenarioTrust string) *models.AddSignals
 func NewAPIC(config *csconfig.OnlineApiClientCfg, dbClient *database.Client, consoleConfig *csconfig.ConsoleConfig) (*apic, error) {
 	var err error
 	ret := &apic{
-		alertToPush:   make(chan []*models.Alert),
-		dbClient:      dbClient,
-		mu:            sync.Mutex{},
-		startup:       true,
-		credentials:   config.Credentials,
-		pullTomb:      tomb.Tomb{},
-		pushTomb:      tomb.Tomb{},
-		metricsTomb:   tomb.Tomb{},
-		scenarioList:  make([]string, 0),
-		consoleConfig: consoleConfig,
-	}
-
-	ret.pullInterval, err = time.ParseDuration(PullInterval)
-	if err != nil {
-		return ret, err
-	}
-	ret.pushInterval, err = time.ParseDuration(PushInterval)
-	if err != nil {
-		return ret, err
-	}
-	ret.metricsInterval, err = time.ParseDuration(MetricsInterval)
-	if err != nil {
-		return ret, err
+		alertToPush:     make(chan []*models.Alert),
+		dbClient:        dbClient,
+		mu:              sync.Mutex{},
+		startup:         true,
+		credentials:     config.Credentials,
+		pullTomb:        tomb.Tomb{},
+		pushTomb:        tomb.Tomb{},
+		metricsTomb:     tomb.Tomb{},
+		scenarioList:    make([]string, 0),
+		consoleConfig:   consoleConfig,
+		pullInterval:    PullInterval,
+		pushInterval:    PushInterval,
+		metricsInterval: MetricsInterval,
 	}
 
 	password := strfmt.Password(config.Credentials.Password)
@@ -140,6 +125,7 @@ func NewAPIC(config *csconfig.OnlineApiClientCfg, dbClient *database.Client, con
 	return ret, err
 }
 
+// keep track of all alerts in cache and push it to CAPI every PushInterval.
 func (a *apic) Push() error {
 	defer types.CatchPanic("lapi/pushToAPIC")
 
@@ -170,39 +156,9 @@ func (a *apic) Push() error {
 		case alerts := <-a.alertToPush:
 			var signals []*models.AddSignalsRequestItem
 			for _, alert := range alerts {
-				if *alert.Simulated {
-					log.Debugf("simulation enabled for alert (id:%d), will not be sent to CAPI", alert.ID)
-					continue
-				}
-				scenarioTrust := "certified"
-				if alert.ScenarioHash == nil || *alert.ScenarioHash == "" {
-					scenarioTrust = "custom"
-				} else if alert.ScenarioVersion == nil || *alert.ScenarioVersion == "" || *alert.ScenarioVersion == "?" {
-					scenarioTrust = "tainted"
-				}
-				if len(alert.Decisions) > 0 {
-					if *alert.Decisions[0].Origin == "cscli" {
-						scenarioTrust = "manual"
-					}
-				}
-				switch scenarioTrust {
-				case "manual":
-					if !*a.consoleConfig.ShareManualDecisions {
-						log.Debugf("manual decision generated an alert, doesn't send it to CAPI because options is disabled")
-						continue
-					}
-				case "tainted":
-					if !*a.consoleConfig.ShareTaintedScenarios {
-						log.Debugf("tainted scenario generated an alert, doesn't send it to CAPI because options is disabled")
-						continue
-					}
-				case "custom":
-					if !*a.consoleConfig.ShareCustomScenarios {
-						log.Debugf("custom scenario generated an alert, doesn't send it to CAPI because options is disabled")
-						continue
-					}
+				if ok := shouldShareAlert(alert, a.consoleConfig); ok {
+					signals = append(signals, alertToSignal(alert, getScenarioTrustOfAlert(alert)))
 				}
-				signals = append(signals, AlertToSignal(alert, scenarioTrust))
 			}
 			a.mu.Lock()
 			cache = append(cache, signals...)
@@ -211,6 +167,46 @@ func (a *apic) Push() error {
 	}
 }
 
+func getScenarioTrustOfAlert(alert *models.Alert) string {
+	scenarioTrust := "certified"
+	if alert.ScenarioHash == nil || *alert.ScenarioHash == "" {
+		scenarioTrust = "custom"
+	} else if alert.ScenarioVersion == nil || *alert.ScenarioVersion == "" || *alert.ScenarioVersion == "?" {
+		scenarioTrust = "tainted"
+	}
+	if len(alert.Decisions) > 0 {
+		if *alert.Decisions[0].Origin == "cscli" {
+			scenarioTrust = "manual"
+		}
+	}
+	return scenarioTrust
+}
+
+func shouldShareAlert(alert *models.Alert, consoleConfig *csconfig.ConsoleConfig) bool {
+	if *alert.Simulated {
+		log.Debugf("simulation enabled for alert (id:%d), will not be sent to CAPI", alert.ID)
+		return false
+	}
+	switch scenarioTrust := getScenarioTrustOfAlert(alert); scenarioTrust {
+	case "manual":
+		if !*consoleConfig.ShareManualDecisions {
+			log.Debugf("manual decision generated an alert, doesn't send it to CAPI because options is disabled")
+			return false
+		}
+	case "tainted":
+		if !*consoleConfig.ShareTaintedScenarios {
+			log.Debugf("tainted scenario generated an alert, doesn't send it to CAPI because options is disabled")
+			return false
+		}
+	case "custom":
+		if !*consoleConfig.ShareCustomScenarios {
+			log.Debugf("custom scenario generated an alert, doesn't send it to CAPI because options is disabled")
+			return false
+		}
+	}
+	return true
+}
+
 func (a *apic) Send(cacheOrig *models.AddSignalsRequest) {
 	/*we do have a problem with this :
 	The apic.Push background routine reads from alertToPush chan.
@@ -256,54 +252,26 @@ func (a *apic) Send(cacheOrig *models.AddSignalsRequest) {
 	}
 }
 
-var SCOPE_CAPI string = "CAPI"
-var SCOPE_CAPI_ALIAS string = "crowdsecurity/community-blocklist" //we don't use "CAPI" directly, to make it less confusing for the user
-var SCOPE_LISTS string = "lists"
-
-func (a *apic) PullTop() error {
-	var err error
-
+func (a *apic) CAPIPullIsOld() (bool, error) {
 	/*only pull community blocklist if it's older than 1h30 */
 	alerts := a.dbClient.Ent.Alert.Query()
 	alerts = alerts.Where(alert.HasDecisionsWith(decision.OriginEQ(database.CapiMachineID)))
 	alerts = alerts.Where(alert.CreatedAtGTE(time.Now().UTC().Add(-time.Duration(1*time.Hour + 30*time.Minute))))
 	count, err := alerts.Count(a.dbClient.CTX)
 	if err != nil {
-		return errors.Wrap(err, "while looking for CAPI alert")
+		return false, errors.Wrap(err, "while looking for CAPI alert")
 	}
 	if count > 0 {
 		log.Printf("last CAPI pull is newer than 1h30, skip.")
-		return nil
-	}
-	data, _, err := a.apiClient.Decisions.GetStream(context.Background(), apiclient.DecisionsStreamOpts{Startup: a.startup})
-	if err != nil {
-		return errors.Wrap(err, "get stream")
-	}
-	if a.startup {
-		a.startup = false
+		return false, nil
 	}
-	/*to count additions/deletions accross lists*/
-	var add_counters map[string]map[string]int
-	var delete_counters map[string]map[string]int
+	return true, nil
+}
 
-	add_counters = make(map[string]map[string]int)
-	add_counters[SCOPE_CAPI] = make(map[string]int)
-	add_counters[SCOPE_LISTS] = make(map[string]int)
-	delete_counters = make(map[string]map[string]int)
-	delete_counters[SCOPE_CAPI] = make(map[string]int)
-	delete_counters[SCOPE_LISTS] = make(map[string]int)
+func (a *apic) HandleDeletedDecisions(deletedDecisions []*models.Decision, delete_counters map[string]map[string]int) (int, error) {
 	var filter map[string][]string
 	var nbDeleted int
-	// process deleted decisions
-	for _, decision := range data.Deleted {
-		//count individual deletions
-		if *decision.Origin == SCOPE_CAPI {
-			delete_counters[SCOPE_CAPI][*decision.Scenario]++
-		} else if *decision.Origin == SCOPE_LISTS {
-			delete_counters[SCOPE_LISTS][*decision.Scenario]++
-		} else {
-			log.Warningf("Unknown origin %s", *decision.Origin)
-		}
+	for _, decision := range deletedDecisions {
 		if strings.ToLower(*decision.Scope) == "ip" {
 			filter = make(map[string][]string, 1)
 			filter["value"] = []string{*decision.Value}
@@ -311,36 +279,30 @@ func (a *apic) PullTop() error {
 			filter = make(map[string][]string, 3)
 			filter["value"] = []string{*decision.Value}
 			filter["type"] = []string{*decision.Type}
-			filter["value"] = []string{*decision.Scope}
+			filter["scopes"] = []string{*decision.Scope}
 		}
+		filter["origin"] = []string{*decision.Origin}
 
 		dbCliRet, err := a.dbClient.SoftDeleteDecisionsWithFilter(filter)
 		if err != nil {
-			return errors.Wrap(err, "deleting decisions error")
+			return 0, errors.Wrap(err, "deleting decisions error")
 		}
 		dbCliDel, err := strconv.Atoi(dbCliRet)
 		if err != nil {
-			return errors.Wrapf(err, "converting db ret %d", dbCliDel)
+			return 0, errors.Wrapf(err, "converting db ret %d", dbCliDel)
 		}
+		updateCounterForDecision(delete_counters, decision, dbCliDel)
 		nbDeleted += dbCliDel
 	}
-	log.Printf("capi/community-blocklist : %d explicit deletions", nbDeleted)
+	return nbDeleted, nil
 
-	if len(data.New) == 0 {
-		log.Warnf("capi/community-blocklist : received 0 new entries, CAPI failure ?")
-		return nil
-	}
-
-	//we receive only one list of decisions, that we need to break-up :
-	// one alert for "community blocklist"
-	// one alert per list we're subscribed to
-	var alertsFromCapi []*models.Alert
-	alertsFromCapi = make([]*models.Alert, 0)
+}
 
-	//iterate over all new decisions, and simply create corresponding alerts
-	for _, decision := range data.New {
+func createAlertsForDecisions(decisions []*models.Decision) []*models.Alert {
+	newAlerts := make([]*models.Alert, 0)
+	for _, decision := range decisions {
 		found := false
-		for _, sub := range alertsFromCapi {
+		for _, sub := range newAlerts {
 			if sub.Source.Scope == nil {
 				log.Warningf("nil scope in %+v", sub)
 				continue
@@ -366,42 +328,44 @@ func (a *apic) PullTop() error {
 		}
 		if !found {
 			log.Debugf("Create entry for origin:%s scenario:%s", *decision.Origin, *decision.Scenario)
-			newAlert := models.Alert{}
-			newAlert.Message = types.StrPtr("")
-			newAlert.Source = &models.Source{}
-			if *decision.Origin == SCOPE_CAPI { //to make things more user friendly, we replace CAPI with community-blocklist
-				newAlert.Source.Scope = types.StrPtr(SCOPE_CAPI)
-				newAlert.Scenario = types.StrPtr(SCOPE_CAPI)
-			} else if *decision.Origin == SCOPE_LISTS {
-				newAlert.Source.Scope = types.StrPtr(SCOPE_LISTS)
-				newAlert.Scenario = types.StrPtr(*decision.Scenario)
-			} else {
-				log.Warningf("unknown origin %s", *decision.Origin)
-			}
-			newAlert.Source.Value = types.StrPtr("")
-			newAlert.StartAt = types.StrPtr(time.Now().UTC().Format(time.RFC3339))
-			newAlert.StopAt = types.StrPtr(time.Now().UTC().Format(time.RFC3339))
-			newAlert.Capacity = types.Int32Ptr(0)
-			newAlert.Simulated = types.BoolPtr(false)
-			newAlert.EventsCount = types.Int32Ptr(int32(len(data.New)))
-			newAlert.Leakspeed = types.StrPtr("")
-			newAlert.ScenarioHash = types.StrPtr("")
-			newAlert.ScenarioVersion = types.StrPtr("")
-			newAlert.MachineID = database.CapiMachineID
-			alertsFromCapi = append(alertsFromCapi, &newAlert)
+			newAlerts = append(newAlerts, createAlertForDecision(decision))
 		}
 	}
+	return newAlerts
+}
 
-	//iterate a second time and fill the alerts with the new decisions
-	for _, decision := range data.New {
+func createAlertForDecision(decision *models.Decision) *models.Alert {
+	newAlert := &models.Alert{}
+	newAlert.Source = &models.Source{}
+	newAlert.Source.Scope = types.StrPtr("")
+	if *decision.Origin == SCOPE_CAPI { //to make things more user friendly, we replace CAPI with community-blocklist
+		newAlert.Scenario = types.StrPtr(SCOPE_CAPI)
+		newAlert.Source.Scope = types.StrPtr(SCOPE_CAPI)
+	} else if *decision.Origin == SCOPE_LISTS {
+		newAlert.Scenario = types.StrPtr(*decision.Scenario)
+		newAlert.Source.Scope = types.StrPtr(SCOPE_LISTS)
+	} else {
+		log.Warningf("unknown origin %s", *decision.Origin)
+	}
+	newAlert.Message = types.StrPtr("")
+	newAlert.Source.Value = types.StrPtr("")
+	newAlert.StartAt = types.StrPtr(time.Now().UTC().Format(time.RFC3339))
+	newAlert.StopAt = types.StrPtr(time.Now().UTC().Format(time.RFC3339))
+	newAlert.Capacity = types.Int32Ptr(0)
+	newAlert.Simulated = types.BoolPtr(false)
+	newAlert.EventsCount = types.Int32Ptr(0)
+	newAlert.Leakspeed = types.StrPtr("")
+	newAlert.ScenarioHash = types.StrPtr("")
+	newAlert.ScenarioVersion = types.StrPtr("")
+	newAlert.MachineID = database.CapiMachineID
+	return newAlert
+}
+
+// This function takes in list of parent alerts and decisions and then pairs them up.
+func fillAlertsWithDecisions(alerts []*models.Alert, decisions []*models.Decision, add_counters map[string]map[string]int) []*models.Alert {
+	for _, decision := range decisions {
 		//count and create separate alerts for each list
-		if *decision.Origin == SCOPE_CAPI {
-			add_counters[SCOPE_CAPI]["all"]++
-		} else if *decision.Origin == SCOPE_LISTS {
-			add_counters[SCOPE_LISTS][*decision.Scenario]++
-		} else {
-			log.Warningf("Unknown origin %s", *decision.Origin)
-		}
+		updateCounterForDecision(add_counters, decision, 1)
 
 		/*CAPI might send lower case scopes, unify it.*/
 		switch strings.ToLower(*decision.Scope) {
@@ -412,16 +376,16 @@ func (a *apic) PullTop() error {
 		}
 		found := false
 		//add the individual decisions to the right list
-		for idx, alert := range alertsFromCapi {
+		for idx, alert := range alerts {
 			if *decision.Origin == SCOPE_CAPI {
 				if *alert.Source.Scope == SCOPE_CAPI {
-					alertsFromCapi[idx].Decisions = append(alertsFromCapi[idx].Decisions, decision)
+					alerts[idx].Decisions = append(alerts[idx].Decisions, decision)
 					found = true
 					break
 				}
 			} else if *decision.Origin == SCOPE_LISTS {
 				if *alert.Source.Scope == SCOPE_LISTS && *alert.Scenario == *decision.Scenario {
-					alertsFromCapi[idx].Decisions = append(alertsFromCapi[idx].Decisions, decision)
+					alerts[idx].Decisions = append(alerts[idx].Decisions, decision)
 					found = true
 					break
 				}
@@ -433,18 +397,49 @@ func (a *apic) PullTop() error {
 			log.Warningf("Orphaned decision for %s - %s", *decision.Origin, *decision.Scenario)
 		}
 	}
+	return alerts
+}
+
+//we receive only one list of decisions, that we need to break-up :
+// one alert for "community blocklist"
+// one alert per list we're subscribed to
+func (a *apic) PullTop() error {
+	var err error
+
+	if lastPullIsOld, err := a.CAPIPullIsOld(); err != nil {
+		return err
+	} else if !lastPullIsOld {
+		return nil
+	}
+
+	data, _, err := a.apiClient.Decisions.GetStream(context.Background(), apiclient.DecisionsStreamOpts{Startup: a.startup})
+	if err != nil {
+		return errors.Wrap(err, "get stream")
+	}
+	a.startup = false
+	/*to count additions/deletions accross lists*/
+
+	add_counters, delete_counters := makeAddAndDeleteCounters()
+	// process deleted decisions
+	if nbDeleted, err := a.HandleDeletedDecisions(data.Deleted, delete_counters); err != nil {
+		return err
+	} else {
+		log.Printf("capi/community-blocklist : %d explicit deletions", nbDeleted)
+	}
+
+	if len(data.New) == 0 {
+		log.Warnf("capi/community-blocklist : received 0 new entries, CAPI failure ?")
+		return nil
+	}
+
+	//we receive only one list of decisions, that we need to break-up :
+	// one alert for "community blocklist"
+	// one alert per list we're subscribed to
+	alertsFromCapi := createAlertsForDecisions(data.New)
+	alertsFromCapi = fillAlertsWithDecisions(alertsFromCapi, data.New, add_counters)
 
 	for idx, alert := range alertsFromCapi {
-		formatted_update := ""
-
-		if *alertsFromCapi[idx].Source.Scope == SCOPE_CAPI {
-			*alertsFromCapi[idx].Source.Scope = SCOPE_CAPI_ALIAS
-			formatted_update = fmt.Sprintf("update : +%d/-%d IPs", add_counters[SCOPE_CAPI]["all"], delete_counters[SCOPE_CAPI]["all"])
-		} else if *alertsFromCapi[idx].Source.Scope == SCOPE_LISTS {
-			*alertsFromCapi[idx].Source.Scope = fmt.Sprintf("%s:%s", SCOPE_LISTS, *alertsFromCapi[idx].Scenario)
-			formatted_update = fmt.Sprintf("update : +%d/-%d IPs", add_counters[SCOPE_LISTS][*alert.Scenario], delete_counters[SCOPE_LISTS][*alert.Scenario])
-		}
-		alertsFromCapi[idx].Scenario = types.StrPtr(formatted_update)
+		alertsFromCapi[idx] = setAlertScenario(add_counters, delete_counters, alert)
 		log.Debugf("%s has %d decisions", *alertsFromCapi[idx].Source.Scope, len(alertsFromCapi[idx].Decisions))
 		alertID, inserted, deleted, err := a.dbClient.UpdateCommunityBlocklist(alertsFromCapi[idx])
 		if err != nil {
@@ -455,14 +450,27 @@ func (a *apic) PullTop() error {
 	return nil
 }
 
+func setAlertScenario(add_counters map[string]map[string]int, delete_counters map[string]map[string]int, alert *models.Alert) *models.Alert {
+	if *alert.Source.Scope == SCOPE_CAPI {
+		*alert.Source.Scope = SCOPE_CAPI_ALIAS
+		alert.Scenario = types.StrPtr(fmt.Sprintf("update : +%d/-%d IPs", add_counters[SCOPE_CAPI]["all"], delete_counters[SCOPE_CAPI]["all"]))
+	} else if *alert.Source.Scope == SCOPE_LISTS {
+		*alert.Source.Scope = fmt.Sprintf("%s:%s", SCOPE_LISTS, *alert.Scenario)
+		alert.Scenario = types.StrPtr(fmt.Sprintf("update : +%d/-%d IPs", add_counters[SCOPE_LISTS][*alert.Scenario], delete_counters[SCOPE_LISTS][*alert.Scenario]))
+	}
+	return alert
+}
+
 func (a *apic) Pull() error {
 	defer types.CatchPanic("lapi/pullFromAPIC")
 	log.Infof("start crowdsec api pull (interval: %s)", PullInterval)
-	var err error
 
-	scenario := a.scenarioList
 	toldOnce := false
 	for {
+		scenario, err := a.FetchScenariosListFromDB()
+		if err != nil {
+			log.Errorf("unable to fetch scenarios from db: %s", err)
+		}
 		if len(scenario) > 0 {
 			break
 		}
@@ -471,10 +479,6 @@ func (a *apic) Pull() error {
 			toldOnce = true
 		}
 		time.Sleep(1 * time.Second)
-		scenario, err = a.FetchScenariosListFromDB()
-		if err != nil {
-			log.Errorf("unable to fetch scenarios from db: %s", err)
-		}
 	}
 	if err := a.PullTop(); err != nil {
 		log.Errorf("capi pull top: %s", err)
@@ -496,9 +500,8 @@ func (a *apic) Pull() error {
 }
 
 func (a *apic) GetMetrics() (*models.Metrics, error) {
-	version := cwversion.VersionStr()
 	metric := &models.Metrics{
-		ApilVersion: &version,
+		ApilVersion: types.StrPtr(cwversion.VersionStr()),
 		Machines:    make([]*models.MetricsAgentInfo, 0),
 		Bouncers:    make([]*models.MetricsBouncerInfo, 0),
 	}
@@ -578,3 +581,25 @@ func (a *apic) Shutdown() {
 	a.pullTomb.Kill(nil)
 	a.metricsTomb.Kill(nil)
 }
+
+func makeAddAndDeleteCounters() (map[string]map[string]int, map[string]map[string]int) {
+	add_counters := make(map[string]map[string]int)
+	add_counters[SCOPE_CAPI] = make(map[string]int)
+	add_counters[SCOPE_LISTS] = make(map[string]int)
+
+	delete_counters := make(map[string]map[string]int)
+	delete_counters[SCOPE_CAPI] = make(map[string]int)
+	delete_counters[SCOPE_LISTS] = make(map[string]int)
+
+	return add_counters, delete_counters
+}
+
+func updateCounterForDecision(counter map[string]map[string]int, decision *models.Decision, totalDecisions int) {
+	if *decision.Origin == SCOPE_CAPI {
+		counter[*decision.Origin]["all"] += totalDecisions
+		return
+	} else if *decision.Origin == SCOPE_LISTS {
+		counter[*decision.Origin][*decision.Scenario] += totalDecisions
+	}
+	log.Warningf("Unknown origin %s", *decision.Origin)
+}

+ 956 - 0
pkg/apiserver/apic_test.go

@@ -0,0 +1,956 @@
+package apiserver
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"os"
+	"reflect"
+	"sort"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/database"
+	"github.com/crowdsecurity/crowdsec/pkg/database/ent/decision"
+	"github.com/crowdsecurity/crowdsec/pkg/database/ent/machine"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/jarcoal/httpmock"
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/tomb.v2"
+)
+
+func getDBClient(t *testing.T) *database.Client {
+	t.Helper()
+	dbPath, err := os.CreateTemp("", "*sqlite")
+	if err != nil {
+		t.Fatal(err)
+	}
+	dbClient, err := database.NewClient(&csconfig.DatabaseCfg{
+		Type:   "sqlite",
+		DbName: "crowdsec",
+		DbPath: dbPath.Name(),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	return dbClient
+}
+
+func getAPIC(t *testing.T) *apic {
+	t.Helper()
+	dbClient := getDBClient(t)
+	return &apic{
+		alertToPush:  make(chan []*models.Alert),
+		dbClient:     dbClient,
+		mu:           sync.Mutex{},
+		startup:      true,
+		pullTomb:     tomb.Tomb{},
+		pushTomb:     tomb.Tomb{},
+		metricsTomb:  tomb.Tomb{},
+		scenarioList: make([]string, 0),
+		consoleConfig: &csconfig.ConsoleConfig{
+			ShareManualDecisions:  types.BoolPtr(false),
+			ShareTaintedScenarios: types.BoolPtr(false),
+			ShareCustomScenarios:  types.BoolPtr(false),
+		},
+	}
+}
+
+func absDiff(a int, b int) (c int) {
+	if c = a - b; c < 0 {
+		return -1 * c
+	}
+	return c
+}
+
+func assertTotalDecisionCount(t *testing.T, dbClient *database.Client, count int) {
+	d := dbClient.Ent.Decision.Query().AllX(context.Background())
+	assert.Len(t, d, count)
+}
+
+func assertTotalValidDecisionCount(t *testing.T, dbClient *database.Client, count int) {
+	d := dbClient.Ent.Decision.Query().Where(
+		decision.UntilGT(time.Now()),
+	).AllX(context.Background())
+	assert.Len(t, d, count)
+}
+
+func jsonMarshalX(v interface{}) []byte {
+	data, err := json.Marshal(v)
+	if err != nil {
+		panic(err)
+	}
+	return data
+}
+
+func assertTotalAlertCount(t *testing.T, dbClient *database.Client, count int) {
+	d := dbClient.Ent.Alert.Query().AllX(context.Background())
+	assert.Len(t, d, count)
+}
+
+func TestAPICCAPIPullIsOld(t *testing.T) {
+	api := getAPIC(t)
+	isOld, err := api.CAPIPullIsOld()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.True(t, isOld)
+
+	decision := api.dbClient.Ent.Decision.Create().
+		SetUntil(time.Now().Add(time.Hour)).
+		SetScenario("crowdsec/test").
+		SetType("IP").
+		SetScope("Country").
+		SetValue("Blah").
+		SetOrigin(SCOPE_CAPI).
+		SaveX(context.Background())
+
+	api.dbClient.Ent.Alert.Create().
+		SetCreatedAt(time.Now()).
+		SetScenario("crowdsec/test").
+		AddDecisions(
+			decision,
+		).
+		SaveX(context.Background())
+
+	isOld, err = api.CAPIPullIsOld()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.False(t, isOld)
+}
+
+func TestAPICFetchScenariosListFromDB(t *testing.T) {
+	api := getAPIC(t)
+	testCases := []struct {
+		name                    string
+		machineIDsWithScenarios map[string]string
+		expectedScenarios       []string
+	}{
+		{
+			name: "Simple one machine with two scenarios",
+			machineIDsWithScenarios: map[string]string{
+				"a": "crowdsecurity/http-bf,crowdsecurity/ssh-bf",
+			},
+			expectedScenarios: []string{"crowdsecurity/ssh-bf", "crowdsecurity/http-bf"},
+		},
+		{
+			name: "Multi machine with custom+hub scenarios",
+			machineIDsWithScenarios: map[string]string{
+				"a": "crowdsecurity/http-bf,crowdsecurity/ssh-bf,my_scenario",
+				"b": "crowdsecurity/http-bf,crowdsecurity/ssh-bf,foo_scenario",
+			},
+			expectedScenarios: []string{"crowdsecurity/ssh-bf", "crowdsecurity/http-bf", "my_scenario", "foo_scenario"},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			for machineID, scenarios := range tc.machineIDsWithScenarios {
+				api.dbClient.Ent.Machine.Create().
+					SetMachineId(machineID).
+					SetPassword(testPassword.String()).
+					SetIpAddress("1.2.3.4").
+					SetScenarios(scenarios).
+					ExecX(context.Background())
+			}
+			scenarios, err := api.FetchScenariosListFromDB()
+			for machineID := range tc.machineIDsWithScenarios {
+				api.dbClient.Ent.Machine.Delete().Where(machine.MachineIdEQ(machineID)).ExecX(context.Background())
+			}
+			if err != nil {
+				t.Fatal(err)
+			} else {
+				sort.Strings(scenarios)
+				sort.Strings(tc.expectedScenarios)
+				assert.Equal(t, scenarios, tc.expectedScenarios)
+			}
+		})
+
+	}
+}
+
+func TestNewAPIC(t *testing.T) {
+	var testConfig *csconfig.OnlineApiClientCfg
+	setConfig := func() {
+		testConfig = &csconfig.OnlineApiClientCfg{
+			Credentials: &csconfig.ApiCredentialsCfg{
+				URL:      "foobar",
+				Login:    "foo",
+				Password: "bar",
+			},
+		}
+	}
+	type args struct {
+		dbClient      *database.Client
+		consoleConfig *csconfig.ConsoleConfig
+	}
+	tests := []struct {
+		name          string
+		args          args
+		wantErr       bool
+		errorContains string
+		action        func()
+	}{
+		{
+			name:   "simple",
+			action: func() {},
+			args: args{
+				dbClient:      getDBClient(t),
+				consoleConfig: LoadTestConfig().API.Server.ConsoleConfig,
+			},
+		},
+		{
+			name:   "error in parsing URL",
+			action: func() { testConfig.Credentials.URL = "foobar http://" },
+			args: args{
+				dbClient:      getDBClient(t),
+				consoleConfig: LoadTestConfig().API.Server.ConsoleConfig,
+			},
+			wantErr:       true,
+			errorContains: "first path segment in URL cannot contain colon",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setConfig()
+			tt.action()
+			_, err := NewAPIC(testConfig, tt.args.dbClient, tt.args.consoleConfig)
+			if tt.wantErr {
+				assert.ErrorContains(t, err, tt.errorContains)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestAPICHandleDeletedDecisions(t *testing.T) {
+	api := getAPIC(t)
+	_, deleteCounters := makeAddAndDeleteCounters()
+
+	decision1 := api.dbClient.Ent.Decision.Create().
+		SetUntil(time.Now().Add(time.Hour)).
+		SetScenario("crowdsec/test").
+		SetType("ban").
+		SetScope("IP").
+		SetValue("1.2.3.4").
+		SetOrigin(SCOPE_CAPI).
+		SaveX(context.Background())
+
+	api.dbClient.Ent.Decision.Create().
+		SetUntil(time.Now().Add(time.Hour)).
+		SetScenario("crowdsec/test").
+		SetType("ban").
+		SetScope("IP").
+		SetValue("1.2.3.4").
+		SetOrigin(SCOPE_CAPI).
+		SaveX(context.Background())
+
+	assertTotalDecisionCount(t, api.dbClient, 2)
+
+	nbDeleted, err := api.HandleDeletedDecisions([]*models.Decision{{
+		Value:    types.StrPtr("1.2.3.4"),
+		Origin:   &SCOPE_CAPI,
+		Type:     &decision1.Type,
+		Scenario: types.StrPtr("crowdsec/test"),
+		Scope:    types.StrPtr("IP"),
+	}}, deleteCounters)
+
+	assert.NoError(t, err)
+	assert.Equal(t, nbDeleted, 2)
+	assert.Equal(t, deleteCounters[SCOPE_CAPI]["all"], 2)
+}
+
+func TestAPICGetMetrics(t *testing.T) {
+	api := getAPIC(t)
+	cleanUp := func() {
+		api.dbClient.Ent.Bouncer.Delete().ExecX(context.Background())
+		api.dbClient.Ent.Machine.Delete().ExecX(context.Background())
+	}
+	testCases := []struct {
+		name           string
+		machineIDs     []string
+		bouncers       []string
+		expectedMetric *models.Metrics
+	}{
+		{
+			name:       "simple",
+			machineIDs: []string{"a", "b", "c"},
+			bouncers:   []string{"1", "2", "3"},
+			expectedMetric: &models.Metrics{
+				ApilVersion: types.StrPtr(cwversion.VersionStr()),
+				Bouncers: []*models.MetricsBouncerInfo{
+					{
+						CustomName: "1",
+						LastPull:   time.Time{}.String(),
+					}, {
+						CustomName: "2",
+						LastPull:   time.Time{}.String(),
+					}, {
+						CustomName: "3",
+						LastPull:   time.Time{}.String(),
+					},
+				},
+				Machines: []*models.MetricsAgentInfo{
+					{
+						Name:       "a",
+						LastPush:   time.Time{}.String(),
+						LastUpdate: time.Time{}.String(),
+					},
+					{
+						Name:       "b",
+						LastPush:   time.Time{}.String(),
+						LastUpdate: time.Time{}.String(),
+					},
+					{
+						Name:       "c",
+						LastPush:   time.Time{}.String(),
+						LastUpdate: time.Time{}.String(),
+					},
+				},
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			cleanUp()
+			for i, machineID := range testCase.machineIDs {
+				api.dbClient.Ent.Machine.Create().
+					SetMachineId(machineID).
+					SetPassword(testPassword.String()).
+					SetIpAddress(fmt.Sprintf("1.2.3.%d", i)).
+					SetScenarios("crowdsecurity/test").
+					SetLastPush(time.Time{}).
+					SetUpdatedAt(time.Time{}).
+					ExecX(context.Background())
+			}
+
+			for i, bouncerName := range testCase.bouncers {
+				api.dbClient.Ent.Bouncer.Create().
+					SetIPAddress(fmt.Sprintf("1.2.3.%d", i)).
+					SetName(bouncerName).
+					SetAPIKey("foobar").
+					SetRevoked(false).
+					SetLastPull(time.Time{}).
+					ExecX(context.Background())
+			}
+
+			if foundMetrics, err := api.GetMetrics(); err != nil {
+				t.Fatal(err)
+			} else {
+				assert.Equal(t, foundMetrics.Bouncers, testCase.expectedMetric.Bouncers)
+				assert.Equal(t, foundMetrics.Machines, testCase.expectedMetric.Machines)
+
+			}
+		})
+	}
+}
+
+func TestCreateAlertsForDecision(t *testing.T) {
+
+	httpBfDecisionList := &models.Decision{
+		Origin:   &SCOPE_LISTS,
+		Scenario: types.StrPtr("crowdsecurity/http-bf"),
+	}
+
+	sshBfDecisionList := &models.Decision{
+		Origin:   &SCOPE_LISTS,
+		Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+	}
+
+	httpBfDecisionCommunity := &models.Decision{
+		Origin:   &SCOPE_CAPI,
+		Scenario: types.StrPtr("crowdsecurity/http-bf"),
+	}
+
+	sshBfDecisionCommunity := &models.Decision{
+		Origin:   &SCOPE_CAPI,
+		Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+	}
+	type args struct {
+		decisions []*models.Decision
+	}
+	tests := []struct {
+		name string
+		args args
+		want []*models.Alert
+	}{
+		{
+			name: "2 decisions CAPI List Decisions should create 2 alerts",
+			args: args{
+				decisions: []*models.Decision{
+					httpBfDecisionList,
+					sshBfDecisionList,
+				},
+			},
+			want: []*models.Alert{
+				createAlertForDecision(httpBfDecisionList),
+				createAlertForDecision(sshBfDecisionList),
+			},
+		},
+		{
+			name: "2 decisions CAPI List same scenario decisions should create 1 alert",
+			args: args{
+				decisions: []*models.Decision{
+					httpBfDecisionList,
+					httpBfDecisionList,
+				},
+			},
+			want: []*models.Alert{
+				createAlertForDecision(httpBfDecisionList),
+			},
+		},
+		{
+			name: "5 decisions from community list should create 1 alert",
+			args: args{
+				decisions: []*models.Decision{
+					httpBfDecisionCommunity,
+					httpBfDecisionCommunity,
+					sshBfDecisionCommunity,
+					sshBfDecisionCommunity,
+					sshBfDecisionCommunity,
+				},
+			},
+			want: []*models.Alert{
+				createAlertForDecision(sshBfDecisionCommunity),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := createAlertsForDecisions(tt.args.decisions); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("createAlertsForDecisions() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestFillAlertsWithDecisions(t *testing.T) {
+	httpBfDecisionCommunity := &models.Decision{
+		Origin:   &SCOPE_CAPI,
+		Scenario: types.StrPtr("crowdsecurity/http-bf"),
+		Scope:    types.StrPtr("ip"),
+	}
+
+	sshBfDecisionCommunity := &models.Decision{
+		Origin:   &SCOPE_CAPI,
+		Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+		Scope:    types.StrPtr("ip"),
+	}
+
+	httpBfDecisionList := &models.Decision{
+		Origin:   &SCOPE_LISTS,
+		Scenario: types.StrPtr("crowdsecurity/http-bf"),
+		Scope:    types.StrPtr("ip"),
+	}
+
+	sshBfDecisionList := &models.Decision{
+		Origin:   &SCOPE_LISTS,
+		Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+		Scope:    types.StrPtr("ip"),
+	}
+	type args struct {
+		alerts    []*models.Alert
+		decisions []*models.Decision
+	}
+	tests := []struct {
+		name string
+		args args
+		want []*models.Alert
+	}{
+		{
+			name: "1 CAPI alert should pair up with n CAPI decisions",
+			args: args{
+				alerts:    []*models.Alert{createAlertForDecision(httpBfDecisionCommunity)},
+				decisions: []*models.Decision{httpBfDecisionCommunity, sshBfDecisionCommunity, sshBfDecisionCommunity, httpBfDecisionCommunity},
+			},
+			want: []*models.Alert{
+				func() *models.Alert {
+					a := createAlertForDecision(httpBfDecisionCommunity)
+					a.Decisions = []*models.Decision{httpBfDecisionCommunity, sshBfDecisionCommunity, sshBfDecisionCommunity, httpBfDecisionCommunity}
+					return a
+				}(),
+			},
+		},
+		{
+			name: "List alert should pair up only with decisions having same scenario",
+			args: args{
+				alerts:    []*models.Alert{createAlertForDecision(httpBfDecisionList), createAlertForDecision(sshBfDecisionList)},
+				decisions: []*models.Decision{httpBfDecisionList, httpBfDecisionList, sshBfDecisionList, sshBfDecisionList},
+			},
+			want: []*models.Alert{
+				func() *models.Alert {
+					a := createAlertForDecision(httpBfDecisionList)
+					a.Decisions = []*models.Decision{httpBfDecisionList, httpBfDecisionList}
+					return a
+				}(),
+				func() *models.Alert {
+					a := createAlertForDecision(sshBfDecisionList)
+					a.Decisions = []*models.Decision{sshBfDecisionList, sshBfDecisionList}
+					return a
+				}(),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			add_counters, _ := makeAddAndDeleteCounters()
+			if got := fillAlertsWithDecisions(tt.args.alerts, tt.args.decisions, add_counters); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("fillAlertsWithDecisions() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestAPICPullTop(t *testing.T) {
+	api := getAPIC(t)
+	api.dbClient.Ent.Decision.Create().
+		SetOrigin(SCOPE_LISTS).
+		SetType("ban").
+		SetValue("9.9.9.9").
+		SetScope("Ip").
+		SetScenario("crowdsecurity/ssh-bf").
+		SetUntil(time.Now().Add(time.Hour)).
+		ExecX(context.Background())
+	assertTotalDecisionCount(t, api.dbClient, 1)
+	assertTotalValidDecisionCount(t, api.dbClient, 1)
+	httpmock.Activate()
+	defer httpmock.DeactivateAndReset()
+	httpmock.RegisterResponder("GET", "http://api.crowdsec.net/api/decisions/stream", httpmock.NewBytesResponder(
+		200, jsonMarshalX(
+			models.DecisionsStreamResponse{
+				Deleted: models.GetDecisionsResponse{
+					&models.Decision{
+						Origin:   &SCOPE_LISTS,
+						Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+						Value:    types.StrPtr("9.9.9.9"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					}, // Thie is already present in DB
+					&models.Decision{
+						Origin:   &SCOPE_LISTS,
+						Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+						Value:    types.StrPtr("9.1.9.9"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					}, // This not present in DB.
+				},
+				New: models.GetDecisionsResponse{
+					&models.Decision{
+						Origin:   &SCOPE_CAPI,
+						Scenario: types.StrPtr("crowdsecurity/test1"),
+						Value:    types.StrPtr("1.2.3.4"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					},
+					&models.Decision{
+						Origin:   &SCOPE_CAPI,
+						Scenario: types.StrPtr("crowdsecurity/test2"),
+						Value:    types.StrPtr("1.2.3.5"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					}, // These two are from community list.
+					&models.Decision{
+						Origin:   &SCOPE_LISTS,
+						Scenario: types.StrPtr("crowdsecurity/http-bf"),
+						Value:    types.StrPtr("1.2.3.6"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					},
+					&models.Decision{
+						Origin:   &SCOPE_LISTS,
+						Scenario: types.StrPtr("crowdsecurity/ssh-bf"),
+						Value:    types.StrPtr("1.2.3.7"),
+						Scope:    types.StrPtr("Ip"),
+						Duration: types.StrPtr("24h"),
+						Type:     types.StrPtr("ban"),
+					}, // These two are from list subscription.
+				},
+			},
+		),
+	))
+	url, err := url.ParseRequestURI("http://api.crowdsec.net/")
+	if err != nil {
+		t.Fatal(err)
+	}
+	apic, err := apiclient.NewDefaultClient(
+		url,
+		"/api",
+		fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		nil,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	api.apiClient = apic
+	err = api.PullTop()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assertTotalDecisionCount(t, api.dbClient, 5)
+	assertTotalValidDecisionCount(t, api.dbClient, 4)
+	assertTotalAlertCount(t, api.dbClient, 3) // 2 for list sub , 1 for community list.
+	alerts := api.dbClient.Ent.Alert.Query().AllX(context.Background())
+	validDecisions := api.dbClient.Ent.Decision.Query().Where(
+		decision.UntilGT(time.Now())).
+		AllX(context.Background())
+
+	decisionScenarioFreq := make(map[string]int)
+	alertScenario := make(map[string]int)
+
+	for _, alert := range alerts {
+		alertScenario[alert.SourceScope]++
+	}
+	assert.Equal(t, len(alertScenario), 3)
+	assert.Equal(t, alertScenario[SCOPE_CAPI_ALIAS], 1)
+	assert.Equal(t, alertScenario["lists:crowdsecurity/ssh-bf"], 1)
+	assert.Equal(t, alertScenario["lists:crowdsecurity/http-bf"], 1)
+
+	for _, decisions := range validDecisions {
+		decisionScenarioFreq[decisions.Scenario]++
+	}
+
+	assert.Equal(t, decisionScenarioFreq["crowdsecurity/http-bf"], 1)
+	assert.Equal(t, decisionScenarioFreq["crowdsecurity/ssh-bf"], 1)
+	assert.Equal(t, decisionScenarioFreq["crowdsecurity/test1"], 1)
+	assert.Equal(t, decisionScenarioFreq["crowdsecurity/test2"], 1)
+}
+
+func TestAPICPush(t *testing.T) {
+
+	testCases := []struct {
+		name          string
+		alerts        []*models.Alert
+		expectedCalls int
+	}{
+		{
+			name: "simple single alert",
+			alerts: []*models.Alert{
+				{
+					Scenario:        types.StrPtr("crowdsec/test"),
+					ScenarioHash:    types.StrPtr("certified"),
+					ScenarioVersion: types.StrPtr("v1.0"),
+					Simulated:       types.BoolPtr(false),
+				},
+			},
+			expectedCalls: 1,
+		},
+		{
+			name: "simulated alert is not pushed",
+			alerts: []*models.Alert{
+				{
+					Scenario:        types.StrPtr("crowdsec/test"),
+					ScenarioHash:    types.StrPtr("certified"),
+					ScenarioVersion: types.StrPtr("v1.0"),
+					Simulated:       types.BoolPtr(true),
+				},
+			},
+			expectedCalls: 0,
+		},
+		{
+			name:          "1 request per 50 alerts",
+			expectedCalls: 2,
+			alerts: func() []*models.Alert {
+				alerts := make([]*models.Alert, 100)
+				for i := 0; i < 100; i++ {
+					alerts[i] = &models.Alert{
+						Scenario:        types.StrPtr("crowdsec/test"),
+						ScenarioHash:    types.StrPtr("certified"),
+						ScenarioVersion: types.StrPtr("v1.0"),
+						Simulated:       types.BoolPtr(false),
+					}
+				}
+				return alerts
+			}(),
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			api := getAPIC(t)
+			api.pushInterval = time.Millisecond
+			url, err := url.ParseRequestURI("http://api.crowdsec.net/")
+			if err != nil {
+				t.Fatal(err)
+			}
+			httpmock.Activate()
+			defer httpmock.DeactivateAndReset()
+			apic, err := apiclient.NewDefaultClient(
+				url,
+				"/api",
+				fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+				nil,
+			)
+			if err != nil {
+				t.Fatal(err)
+			}
+			api.apiClient = apic
+			httpmock.RegisterResponder("POST", "http://api.crowdsec.net/api/signals", httpmock.NewBytesResponder(200, []byte{}))
+			go func() {
+				api.alertToPush <- testCase.alerts
+				time.Sleep(time.Second)
+				api.Shutdown()
+			}()
+			if err := api.Push(); err != nil {
+				t.Fatal(err)
+			}
+			assert.Equal(t, httpmock.GetTotalCallCount(), testCase.expectedCalls)
+		})
+	}
+}
+
+func TestAPICSendMetrics(t *testing.T) {
+	api := getAPIC(t)
+	testCases := []struct {
+		name            string
+		duration        time.Duration
+		expectedCalls   int
+		setUp           func()
+		metricsInterval time.Duration
+	}{
+		{
+			name:            "basic",
+			duration:        time.Millisecond * 5,
+			metricsInterval: time.Millisecond,
+			expectedCalls:   5,
+			setUp:           func() {},
+		},
+		{
+			name:            "with some metrics",
+			duration:        time.Millisecond * 5,
+			metricsInterval: time.Millisecond,
+			expectedCalls:   5,
+			setUp: func() {
+				api.dbClient.Ent.Machine.Create().
+					SetMachineId("1234").
+					SetPassword(testPassword.String()).
+					SetIpAddress("1.2.3.4").
+					SetScenarios("crowdsecurity/test").
+					SetLastPush(time.Time{}).
+					SetUpdatedAt(time.Time{}).
+					ExecX(context.Background())
+
+				api.dbClient.Ent.Bouncer.Create().
+					SetIPAddress("1.2.3.6").
+					SetName("someBouncer").
+					SetAPIKey("foobar").
+					SetRevoked(false).
+					SetLastPull(time.Time{}).
+					ExecX(context.Background())
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			api = getAPIC(t)
+			api.pushInterval = time.Millisecond
+			url, err := url.ParseRequestURI("http://api.crowdsec.net/")
+			if err != nil {
+				t.Fatal(err)
+			}
+			httpmock.Activate()
+			defer httpmock.DeactivateAndReset()
+			apic, err := apiclient.NewDefaultClient(
+				url,
+				"/api",
+				fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+				nil,
+			)
+			if err != nil {
+				t.Fatal(err)
+			}
+			api.apiClient = apic
+			api.metricsInterval = testCase.metricsInterval
+			httpmock.RegisterNoResponder(httpmock.NewBytesResponder(200, []byte{}))
+			testCase.setUp()
+
+			go func() {
+				if err := api.SendMetrics(); err != nil {
+					panic(err)
+				}
+			}()
+			time.Sleep(testCase.duration)
+			assert.LessOrEqual(t, absDiff(testCase.expectedCalls, httpmock.GetTotalCallCount()), 2)
+		})
+	}
+}
+
+func TestAPICPull(t *testing.T) {
+	api := getAPIC(t)
+	testCases := []struct {
+		name                  string
+		setUp                 func()
+		expectedDecisionCount int
+		logContains           string
+	}{
+		{
+			name:        "test pull if no scenarios are present",
+			setUp:       func() {},
+			logContains: "scenario list is empty, will not pull yet",
+		},
+		{
+			name: "test pull",
+			setUp: func() {
+				api.dbClient.Ent.Machine.Create().
+					SetMachineId("1.2.3.4").
+					SetPassword(testPassword.String()).
+					SetIpAddress("1.2.3.4").
+					SetScenarios("crowdsecurity/ssh-bf").
+					ExecX(context.Background())
+			},
+			expectedDecisionCount: 1,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			api = getAPIC(t)
+			api.pullInterval = time.Millisecond
+			url, err := url.ParseRequestURI("http://api.crowdsec.net/")
+			if err != nil {
+				t.Fatal(err)
+			}
+			httpmock.Activate()
+			defer httpmock.DeactivateAndReset()
+			apic, err := apiclient.NewDefaultClient(
+				url,
+				"/api",
+				fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+				nil,
+			)
+			if err != nil {
+				t.Fatal(err)
+			}
+			api.apiClient = apic
+			httpmock.RegisterNoResponder(httpmock.NewBytesResponder(200, jsonMarshalX(
+				models.DecisionsStreamResponse{
+					New: models.GetDecisionsResponse{
+						&models.Decision{
+							Origin:   &SCOPE_CAPI,
+							Scenario: types.StrPtr("crowdsecurity/test2"),
+							Value:    types.StrPtr("1.2.3.5"),
+							Scope:    types.StrPtr("Ip"),
+							Duration: types.StrPtr("24h"),
+							Type:     types.StrPtr("ban"),
+						},
+					},
+				},
+			)))
+			testCase.setUp()
+			var buf bytes.Buffer
+			go func() {
+				logrus.SetOutput(&buf)
+				if err := api.Pull(); err != nil {
+					panic(err)
+				}
+			}()
+			time.Sleep(time.Millisecond * 10)
+			logrus.SetOutput(os.Stderr)
+			assert.Contains(t, buf.String(), testCase.logContains)
+			assertTotalDecisionCount(t, api.dbClient, testCase.expectedDecisionCount)
+		})
+	}
+}
+
+func TestShouldShareAlert(t *testing.T) {
+
+	testCases := []struct {
+		name          string
+		consoleConfig *csconfig.ConsoleConfig
+		alert         *models.Alert
+		expectedRet   bool
+		expectedTrust string
+	}{
+		{
+			name: "custom alert should be shared if config enables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareCustomScenarios: types.BoolPtr(true),
+			},
+			alert:         &models.Alert{Simulated: types.BoolPtr(false)},
+			expectedRet:   true,
+			expectedTrust: "custom",
+		},
+		{
+			name: "custom alert should not be shared if config disables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareCustomScenarios: types.BoolPtr(false),
+			},
+			alert:         &models.Alert{Simulated: types.BoolPtr(false)},
+			expectedRet:   false,
+			expectedTrust: "custom",
+		},
+		{
+			name: "manual alert should be shared if config enables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareManualDecisions: types.BoolPtr(true),
+			},
+			alert: &models.Alert{
+				Simulated: types.BoolPtr(false),
+				Decisions: []*models.Decision{{Origin: types.StrPtr("cscli")}},
+			},
+			expectedRet:   true,
+			expectedTrust: "manual",
+		},
+		{
+			name: "manaul alert should not be shared if config disables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareManualDecisions: types.BoolPtr(false),
+			},
+			alert: &models.Alert{
+				Simulated: types.BoolPtr(false),
+				Decisions: []*models.Decision{{Origin: types.StrPtr("cscli")}},
+			},
+			expectedRet:   false,
+			expectedTrust: "manual",
+		},
+		{
+			name: "manual alert should be shared if config enables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareTaintedScenarios: types.BoolPtr(true),
+			},
+			alert: &models.Alert{
+				Simulated:    types.BoolPtr(false),
+				ScenarioHash: types.StrPtr("whateverHash"),
+			},
+			expectedRet:   true,
+			expectedTrust: "tainted",
+		},
+		{
+			name: "manaul alert should not be shared if config disables it",
+			consoleConfig: &csconfig.ConsoleConfig{
+				ShareTaintedScenarios: types.BoolPtr(false),
+			},
+			alert: &models.Alert{
+				Simulated:    types.BoolPtr(false),
+				ScenarioHash: types.StrPtr("whateverHash"),
+			},
+			expectedRet:   false,
+			expectedTrust: "tainted",
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			ret := shouldShareAlert(testCase.alert, testCase.consoleConfig)
+			assert.Equal(t, ret, testCase.expectedRet)
+		})
+	}
+}

+ 77 - 4
pkg/apiserver/apiserver_test.go

@@ -3,7 +3,6 @@ package apiserver
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -16,6 +15,7 @@ import (
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/crowdsecurity/crowdsec/pkg/types"
 	"github.com/go-openapi/strfmt"
+	"github.com/pkg/errors"
 
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
 	"github.com/crowdsecurity/crowdsec/pkg/database"
@@ -33,6 +33,7 @@ var MachineTest = models.WatcherAuthRequest{
 }
 
 var UserAgent = fmt.Sprintf("crowdsec-test/%s", cwversion.Version)
+var emptyBody = strings.NewReader("")
 
 func LoadTestConfig() csconfig.Config {
 	config := csconfig.Config{}
@@ -177,6 +178,79 @@ func GetMachineIP(machineID string) (string, error) {
 	return "", nil
 }
 
+func GetAlertReaderFromFile(path string) *strings.Reader {
+
+	alertContentBytes, err := os.ReadFile(path)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	alerts := make([]*models.Alert, 0)
+	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
+		log.Fatal(err)
+	}
+
+	for _, alert := range alerts {
+		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
+		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
+	}
+
+	alertContent, err := json.Marshal(alerts)
+	if err != nil {
+		log.Fatal(err)
+	}
+	return strings.NewReader(string(alertContent))
+
+}
+
+func readDecisionsGetResp(resp *httptest.ResponseRecorder) ([]*models.Decision, int, error) {
+	var response []*models.Decision
+	if resp == nil {
+		return nil, 0, errors.New("response is nil")
+	}
+	err := json.Unmarshal(resp.Body.Bytes(), &response)
+	if err != nil {
+		return nil, resp.Code, err
+	}
+	return response, resp.Code, nil
+}
+
+func readDecisionsErrorResp(resp *httptest.ResponseRecorder) (map[string]string, int, error) {
+	var response map[string]string
+	if resp == nil {
+		return nil, 0, errors.New("response is nil")
+	}
+	err := json.Unmarshal(resp.Body.Bytes(), &response)
+	if err != nil {
+		return nil, resp.Code, err
+	}
+	return response, resp.Code, nil
+}
+
+func readDecisionsDeleteResp(resp *httptest.ResponseRecorder) (*models.DeleteDecisionResponse, int, error) {
+	var response models.DeleteDecisionResponse
+	if resp == nil {
+		return nil, 0, errors.New("response is nil")
+	}
+	err := json.Unmarshal(resp.Body.Bytes(), &response)
+	if err != nil {
+		return nil, resp.Code, err
+	}
+	return &response, resp.Code, nil
+}
+
+func readDecisionsStreamResp(resp *httptest.ResponseRecorder) (map[string][]*models.Decision, int, error) {
+	response := make(map[string][]*models.Decision)
+	if resp == nil {
+		return nil, 0, errors.New("response is nil")
+	}
+	err := json.Unmarshal(resp.Body.Bytes(), &response)
+	if err != nil {
+		return nil, resp.Code, err
+	}
+	return response, resp.Code, nil
+}
+
 func CreateTestMachine(router *gin.Engine) (string, error) {
 	b, err := json.Marshal(MachineTest)
 	if err != nil {
@@ -306,7 +380,7 @@ func TestLoggingDebugToFileConfig(t *testing.T) {
 	time.Sleep(500 * time.Millisecond)
 
 	//check file content
-	data, err := ioutil.ReadFile(expectedFile)
+	data, err := os.ReadFile(expectedFile)
 	if err != nil {
 		t.Fatalf("failed to read file : %s", err)
 	}
@@ -368,12 +442,11 @@ func TestLoggingErrorToFileConfig(t *testing.T) {
 	time.Sleep(500 * time.Millisecond)
 
 	//check file content
-	x, err := ioutil.ReadFile(expectedFile)
+	x, err := os.ReadFile(expectedFile)
 	if err == nil && len(x) > 0 {
 		t.Fatalf("file should be empty, got '%s'", x)
 	}
 
 	os.Remove("./crowdsec.log")
 	os.Remove(expectedFile)
-
 }

+ 304 - 446
pkg/apiserver/decisions_test.go

@@ -1,571 +1,429 @@
 package apiserver
 
 import (
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"net/http/httptest"
-	"strings"
 	"testing"
-	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/models"
-	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestDeleteDecisionRange(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_minibulk.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi.InsertAlertFromFile("./tests/alert_minibulk.json")
 
 	// delete by ip wrong
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?range=1.2.3.0/24", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	w := lapi.RecordResponse("DELETE", "/v1/decisions?range=1.2.3.0/24", emptyBody)
 	assert.Equal(t, 200, w.Code)
+
 	assert.Equal(t, `{"nbDeleted":"0"}`, w.Body.String())
 
 	// delete by range
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?range=91.121.79.0/24&contains=false", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("DELETE", "/v1/decisions?range=91.121.79.0/24&contains=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"2"}`, w.Body.String())
 
 	// delete by range : ensure it was already deleted
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?range=91.121.79.0/24", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("DELETE", "/v1/decisions?range=91.121.79.0/24", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"0"}`, w.Body.String())
 }
 
 func TestDeleteDecisionFilter(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_minibulk.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi.InsertAlertFromFile("./tests/alert_minibulk.json")
 
 	// delete by ip wrong
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?ip=1.2.3.4", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w := lapi.RecordResponse("DELETE", "/v1/decisions?ip=1.2.3.4", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"0"}`, w.Body.String())
 
 	// delete by ip good
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?ip=91.121.79.179", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("DELETE", "/v1/decisions?ip=91.121.79.179", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"1"}`, w.Body.String())
 
 	// delete by scope/value
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?scopes=Ip&value=91.121.79.178", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("DELETE", "/v1/decisions?scopes=Ip&value=91.121.79.178", emptyBody)
 	assert.Equal(t, 200, w.Code)
 	assert.Equal(t, `{"nbDeleted":"1"}`, w.Body.String())
 }
 
 func TestGetDecisionFilters(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_minibulk.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
-	APIKey, err := CreateTestBouncer()
-	if err != nil {
-		log.Fatalf("%s", err.Error())
-	}
+	lapi.InsertAlertFromFile("./tests/alert_minibulk.json")
 
 	// Get Decision
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+
+	w := lapi.RecordResponse("GET", "/v1/decisions", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
-	assert.Contains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
+	decisions, code, err := readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 2, len(decisions))
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[0].Scenario)
+	assert.Equal(t, "91.121.79.179", *decisions[0].Value)
+	assert.Equal(t, int64(1), decisions[0].ID)
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[1].Scenario)
+	assert.Equal(t, "91.121.79.178", *decisions[1].Value)
+	assert.Equal(t, int64(2), decisions[1].ID)
 
 	// Get Decision : type filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions?type=ban", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/decisions?type=ban", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
-	assert.Contains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
+	decisions, code, err = readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 2, len(decisions))
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[0].Scenario)
+	assert.Equal(t, "91.121.79.179", *decisions[0].Value)
+	assert.Equal(t, int64(1), decisions[0].ID)
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[1].Scenario)
+	assert.Equal(t, "91.121.79.178", *decisions[1].Value)
+	assert.Equal(t, int64(2), decisions[1].ID)
+
+	// assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
+	// assert.Contains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
 
 	// Get Decision : scope/value
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions?scopes=Ip&value=91.121.79.179", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/decisions?scopes=Ip&value=91.121.79.179", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
-	assert.NotContains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
+	decisions, code, err = readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 1, len(decisions))
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[0].Scenario)
+	assert.Equal(t, "91.121.79.179", *decisions[0].Value)
+	assert.Equal(t, int64(1), decisions[0].ID)
+
+	// assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
+	// assert.NotContains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
 
 	// Get Decision : ip filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions?ip=91.121.79.179", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+
+	w = lapi.RecordResponse("GET", "/v1/decisions?ip=91.121.79.179", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
-	assert.NotContains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
+	decisions, code, err = readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 1, len(decisions))
+	assert.Equal(t, "crowdsecurity/ssh-bf", *decisions[0].Scenario)
+	assert.Equal(t, "91.121.79.179", *decisions[0].Value)
+	assert.Equal(t, int64(1), decisions[0].ID)
+
+	// assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
+	// assert.NotContains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
 
 	// Get decision : by range
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions?range=91.121.79.0/24&contains=false", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+	w = lapi.RecordResponse("GET", "/v1/decisions?range=91.121.79.0/24&contains=false", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), `"id":1,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.179"`)
-	assert.Contains(t, w.Body.String(), `"id":2,"origin":"crowdsec","scenario":"crowdsecurity/ssh-bf","scope":"Ip","type":"ban","value":"91.121.79.178"`)
+	decisions, code, err = readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 2, len(decisions))
+	assert.Contains(t, []string{*decisions[0].Value, *decisions[1].Value}, "91.121.79.179")
+	assert.Contains(t, []string{*decisions[0].Value, *decisions[1].Value}, "91.121.79.178")
+
 }
 
 func TestGetDecision(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
-	APIKey, err := CreateTestBouncer()
-	if err != nil {
-		log.Fatalf("%s", err.Error())
-	}
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 
 	// Get Decision
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
+	w := lapi.RecordResponse("GET", "/v1/decisions", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test\",\"scenario\":\"crowdsecurity/test\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"}]")
+	decisions, code, err := readDecisionsGetResp(w)
+	assert.Nil(t, err)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, 3, len(decisions))
+	/*decisions get doesn't perform deduplication*/
+	assert.Equal(t, "crowdsecurity/test", *decisions[0].Scenario)
+	assert.Equal(t, "127.0.0.1", *decisions[0].Value)
+	assert.Equal(t, int64(1), decisions[0].ID)
+
+	assert.Equal(t, "crowdsecurity/test", *decisions[1].Scenario)
+	assert.Equal(t, "127.0.0.1", *decisions[1].Value)
+	assert.Equal(t, int64(2), decisions[1].ID)
+
+	assert.Equal(t, "crowdsecurity/test", *decisions[2].Scenario)
+	assert.Equal(t, "127.0.0.1", *decisions[2].Value)
+	assert.Equal(t, int64(3), decisions[2].ID)
 
 	// Get Decision with invalid filter. It should ignore this filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions?test=test", strings.NewReader(""))
-	req.Header.Add("User-Agent", UserAgent)
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
+	w = lapi.RecordResponse("GET", "/v1/decisions?test=test", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test\",\"scenario\":\"crowdsecurity/test\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"}]")
-
+	assert.Equal(t, 3, len(decisions))
 }
 
 func TestDeleteDecisionByID(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 
-	// Delete alert with Invalid ID
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/test", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	//Have one alerts
+	w := lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err := readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
 
+	// Delete alert with Invalid ID
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/test", emptyBody)
 	assert.Equal(t, 400, w.Code)
-	assert.Equal(t, "{\"message\":\"decision_id must be valid integer\"}", w.Body.String())
+	err_resp, _, err := readDecisionsErrorResp(w)
+	assert.NoError(t, err)
+	assert.Equal(t, err_resp["message"], "decision_id must be valid integer")
 
 	// Delete alert with ID that not exist
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/100", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/100", emptyBody)
 	assert.Equal(t, 500, w.Code)
-	assert.Equal(t, "{\"message\":\"decision with id '100' doesn't exist: unable to delete\"}", w.Body.String())
+	err_resp, _, err = readDecisionsErrorResp(w)
+	assert.NoError(t, err)
+	assert.Equal(t, err_resp["message"], "decision with id '100' doesn't exist: unable to delete")
+
+	//Have one alerts
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
 
 	// Delete alert with valid ID
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/1", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/1", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Equal(t, "{\"nbDeleted\":\"1\"}", w.Body.String())
-
+	resp, _, err := readDecisionsDeleteResp(w)
+	assert.NoError(t, err)
+	assert.Equal(t, resp.NbDeleted, "1")
+
+	//Have one alert (because we delete an alert that has dup targets)
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
 }
 
 func TestDeleteDecision(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
 
 	// Delete alert with Invalid filter
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions?test=test", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
-
+	w := lapi.RecordResponse("DELETE", "/v1/decisions?test=test", emptyBody)
 	assert.Equal(t, 500, w.Code)
-	assert.Equal(t, "{\"message\":\"'test' doesn't exist: invalid filter\"}", w.Body.String())
-
-	// Delete alert
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("DELETE", "/v1/decisions", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	err_resp, _, err := readDecisionsErrorResp(w)
+	assert.NoError(t, err)
+	assert.Equal(t, err_resp["message"], "'test' doesn't exist: invalid filter")
 
+	// Delete all alert
+	w = lapi.RecordResponse("DELETE", "/v1/decisions", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Equal(t, "{\"nbDeleted\":\"3\"}", w.Body.String())
-
+	resp, _, err := readDecisionsDeleteResp(w)
+	assert.NoError(t, err)
+	assert.Equal(t, resp.NbDeleted, "3")
 }
 
-func TestStreamDecision(t *testing.T) {
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
-
-	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_sample.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-	req, _ := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", loginResp.Token))
-	router.ServeHTTP(w, req)
-
-	APIKey, err := CreateTestBouncer()
-	if err != nil {
-		log.Fatalf("%s", err.Error())
-	}
-
-	// Get Stream
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
+func TestStreamStartDecisionDedup(t *testing.T) {
+	//Ensure that at stream startup we only get the longest decision
+	lapi := SetupLAPITest(t)
+
+	// Create Valid Alert : 3 decisions for 127.0.0.1, longest has id=3
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
+
+	// Get Stream, we only get one decision (the longest one)
+	w := lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err := readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(3))
+	assert.Equal(t, *decisions["new"][0].Origin, "test")
+	assert.Equal(t, *decisions["new"][0].Value, "127.0.0.1")
+
+	// id=3 decision is deleted, this won't affect `deleted`, because there are decisions on the same ip
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/3", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Equal(t, "{\"deleted\":null,\"new\":null}", w.Body.String())
 
-	// Get Stream just startup
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+	// Get Stream, we only get one decision (the longest one, id=2)
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(2))
+	assert.Equal(t, *decisions["new"][0].Origin, "test")
+	assert.Equal(t, *decisions["new"][0].Value, "127.0.0.1")
 
-	// the decision with id=3 is only returned because it's the longest decision
+	// We delete another decision, yet don't receive it in stream, since there's another decision on same IP
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/2", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test\",\"scenario\":\"crowdsecurity/test\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"}]}")
-	assert.NotContains(t, w.Body.String(), "\"id\":2")
-	assert.NotContains(t, w.Body.String(), "\"id\":1")
-	assert.Contains(t, w.Body.String(), "2h")
-
-	// id=3 decision is deleted, this won't affect `deleted`, because there are decisions
-	// targetting same IP
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/3", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+
+	// And get the remaining decision (1)
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(1))
+	assert.Equal(t, *decisions["new"][0].Origin, "test")
+	assert.Equal(t, *decisions["new"][0].Value, "127.0.0.1")
+
+	// We delete the last decision, we receive the delete order
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/1", emptyBody)
 	assert.Equal(t, 200, w.Code)
 
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+	//and now we only get a deleted decision
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 1)
+	assert.Equal(t, decisions["deleted"][0].ID, int64(1))
+	assert.Equal(t, *decisions["deleted"][0].Origin, "test")
+	assert.Equal(t, *decisions["deleted"][0].Value, "127.0.0.1")
+	assert.Equal(t, len(decisions["new"]), 0)
+}
 
+func TestStreamDecisionDedup(t *testing.T) {
+	//Ensure that at stream startup we only get the longest decision
+	lapi := SetupLAPITest(t)
+
+	// Create Valid Alert : 3 decisions for 127.0.0.1, longest has id=3
+	lapi.InsertAlertFromFile("./tests/alert_sample.json")
+
+	// Get Stream, we only get one decision (the longest one)
+	w := lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err := readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(3))
+	assert.Equal(t, *decisions["new"][0].Origin, "test")
+	assert.Equal(t, *decisions["new"][0].Value, "127.0.0.1")
+
+	// id=3 decision is deleted, this won't affect `deleted`, because there are decisions on the same ip
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/3", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	// the decision with id=2 is only returned because it's the longest decision
-	assert.Contains(t, w.Body.String(), "\"id\":2,\"origin\":\"test\",\"scenario\":\"crowdsecurity/test\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"}]}")
-	assert.NotContains(t, w.Body.String(), "\"id\":3")
-	assert.NotContains(t, w.Body.String(), "\"id\":1")
-	assert.Contains(t, w.Body.String(), "1h")
-	assert.Contains(t, w.Body.String(), "\"deleted\":null")
+
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream", emptyBody)
+	assert.Equal(t, err, nil)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 0)
 
 	// We delete another decision, yet don't receive it in stream, since there's another decision on same IP
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/2", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/2", emptyBody)
+	assert.Equal(t, 200, w.Code)
 
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 0)
 
+	// We delete the last decision, we receive the delete order
+	w = lapi.RecordResponse("DELETE", "/v1/decisions/1", emptyBody)
 	assert.Equal(t, 200, w.Code)
-	assert.Equal(t, "{\"deleted\":null,\"new\":null}", w.Body.String())
 
-	// Now all decisions for this IP are deleted, we should receive it in stream
-	req, _ = http.NewRequest("DELETE", "/v1/decisions/1", strings.NewReader(""))
-	AddAuthHeaders(req, loginResp)
-	router.ServeHTTP(w, req)
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, code, 200)
+	assert.Equal(t, len(decisions["deleted"]), 1)
+	assert.Equal(t, decisions["deleted"][0].ID, int64(1))
+	assert.Equal(t, *decisions["deleted"][0].Origin, "test")
+	assert.Equal(t, *decisions["deleted"][0].Value, "127.0.0.1")
+	assert.Equal(t, len(decisions["new"]), 0)
 }
+
 func TestStreamDecisionFilters(t *testing.T) {
 
-	router, loginResp, err := InitMachineTest()
-	if err != nil {
-		log.Fatalln(err.Error())
-	}
+	lapi := SetupLAPITest(t)
 
 	// Create Valid Alert
-	alertContentBytes, err := ioutil.ReadFile("./tests/alert_stream_fixture.json")
-	if err != nil {
-		log.Fatal(err)
-	}
-	alerts := make([]*models.Alert, 0)
-	if err := json.Unmarshal(alertContentBytes, &alerts); err != nil {
-		log.Fatal(err)
-	}
-
-	for _, alert := range alerts {
-		*alert.StartAt = time.Now().UTC().Format(time.RFC3339)
-		*alert.StopAt = time.Now().UTC().Format(time.RFC3339)
-	}
-
-	alertContent, err := json.Marshal(alerts)
-	if err != nil {
-		log.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-	req, err := http.NewRequest("POST", "/v1/alerts", strings.NewReader(string(alertContent)))
-	if err != nil {
-		log.Fatalf("%s", err.Error())
-	}
-	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", loginResp.Token))
-	router.ServeHTTP(w, req)
-
-	APIKey, err := CreateTestBouncer()
-	if err != nil {
-		log.Fatalf("%s", err.Error())
-	}
-
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
-	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":1,\"origin\":\"test1\",\"scenario\":\"crowdsecurity/http_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":2,\"origin\":\"test2\",\"scenario\":\"crowdsecurity/ssh_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test3\",\"scenario\":\"crowdsecurity/ddos\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
+	lapi.InsertAlertFromFile("./tests/alert_stream_fixture.json")
+
+	w := lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true", emptyBody)
+	decisions, code, err := readDecisionsStreamResp(w)
+
+	assert.Equal(t, 200, code)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 3)
+	assert.Equal(t, decisions["new"][0].ID, int64(1))
+	assert.Equal(t, *decisions["new"][0].Origin, "test1")
+	assert.Equal(t, *decisions["new"][0].Value, "127.0.0.1")
+	assert.Equal(t, *decisions["new"][0].Scenario, "crowdsecurity/http_bf")
+	assert.Equal(t, decisions["new"][1].ID, int64(2))
+	assert.Equal(t, *decisions["new"][1].Origin, "test2")
+	assert.Equal(t, *decisions["new"][1].Value, "127.0.0.1")
+	assert.Equal(t, *decisions["new"][1].Scenario, "crowdsecurity/ssh_bf")
+	assert.Equal(t, decisions["new"][2].ID, int64(3))
+	assert.Equal(t, *decisions["new"][2].Origin, "test3")
+	assert.Equal(t, *decisions["new"][2].Value, "127.0.0.1")
+	assert.Equal(t, *decisions["new"][2].Scenario, "crowdsecurity/ddos")
 
 	// test filter scenarios_not_containing
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true&scenarios_not_containing=http", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
-	assert.Equal(t, 200, w.Code)
-	assert.NotContains(t, w.Body.String(), "\"id\":1,\"origin\":\"test1\",\"scenario\":\"crowdsecurity/http_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":2,\"origin\":\"test2\",\"scenario\":\"crowdsecurity/ssh_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test3\",\"scenario\":\"crowdsecurity/ddos\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true&scenarios_not_containing=http", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 2)
+	assert.Equal(t, decisions["new"][0].ID, int64(2))
+	assert.Equal(t, decisions["new"][1].ID, int64(3))
 
 	// test  filter scenarios_containing
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true&scenarios_containing=http", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
-	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":1,\"origin\":\"test1\",\"scenario\":\"crowdsecurity/http_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.NotContains(t, w.Body.String(), "\"id\":2,\"origin\":\"test2\",\"scenario\":\"crowdsecurity/ssh_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.NotContains(t, w.Body.String(), "\"id\":3,\"origin\":\"test3\",\"scenario\":\"crowdsecurity/ddos\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true&scenarios_containing=http", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(1))
 
 	// test filters both by scenarios_not_containing and scenarios_containing
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true&scenarios_not_containing=ssh&scenarios_containing=ddos", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
-	assert.Equal(t, 200, w.Code)
-	assert.NotContains(t, w.Body.String(), "\"id\":1,\"origin\":\"test1\",\"scenario\":\"crowdsecurity/http_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.NotContains(t, w.Body.String(), "\"id\":2,\"origin\":\"test2\",\"scenario\":\"crowdsecurity/ssh_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":3,\"origin\":\"test3\",\"scenario\":\"crowdsecurity/ddos\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true&scenarios_not_containing=ssh&scenarios_containing=ddos", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 1)
+	assert.Equal(t, decisions["new"][0].ID, int64(3))
 
 	// test filter by origin
-	w = httptest.NewRecorder()
-	req, _ = http.NewRequest("GET", "/v1/decisions/stream?startup=true&origins=test1,test2", strings.NewReader(""))
-	req.Header.Add("X-Api-Key", APIKey)
-	router.ServeHTTP(w, req)
-
-	assert.Equal(t, 200, w.Code)
-	assert.Contains(t, w.Body.String(), "\"id\":1,\"origin\":\"test1\",\"scenario\":\"crowdsecurity/http_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.Contains(t, w.Body.String(), "\"id\":2,\"origin\":\"test2\",\"scenario\":\"crowdsecurity/ssh_bf\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
-	assert.NotContains(t, w.Body.String(), "\"id\":3,\"origin\":\"test3\",\"scenario\":\"crowdsecurity/ddos\",\"scope\":\"Ip\",\"type\":\"ban\",\"value\":\"127.0.0.1\"")
+	w = lapi.RecordResponse("GET", "/v1/decisions/stream?startup=true&origins=test1,test2", emptyBody)
+	decisions, code, err = readDecisionsStreamResp(w)
+	assert.Equal(t, err, nil)
+	assert.Equal(t, 200, code)
+	assert.Equal(t, len(decisions["deleted"]), 0)
+	assert.Equal(t, len(decisions["new"]), 2)
+	assert.Equal(t, decisions["new"][0].ID, int64(1))
+	assert.Equal(t, decisions["new"][1].ID, int64(2))
 }

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
pkg/apiserver/tests/alert_bulk.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
pkg/apiserver/tests/alert_minibulk+simul.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
pkg/apiserver/tests/alert_minibulk.json


+ 1 - 1
pkg/apiserver/tests/alert_sample.json

@@ -74,4 +74,4 @@
         "start_at": "2020-10-09T10:00:01Z",
         "stop_at": "2020-10-09T10:00:05Z"
     }
-]
+]

+ 1 - 1
pkg/apiserver/tests/alert_ssh-bf.json

@@ -272,4 +272,4 @@
     "start_at": "2020-10-26T09:50:32.025353849+01:00",
     "stop_at": "2020-10-26T09:50:32.055534398+01:00"
   }
-]
+]

+ 3 - 5
pkg/csplugin/broker.go

@@ -30,7 +30,6 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
-var testMode bool = false
 var pluginMutex sync.Mutex
 
 const (
@@ -255,6 +254,9 @@ func (pb *PluginBroker) loadNotificationPlugin(name string, binaryPath string) (
 	}
 	cmd := exec.Command(binaryPath)
 	if pb.pluginProcConfig.User != "" || pb.pluginProcConfig.Group != "" {
+		if !(pb.pluginProcConfig.User != "" && pb.pluginProcConfig.Group != "") {
+			return nil, errors.New("while getting process attributes: both plugin user and group must be set")
+		}
 		cmd.SysProcAttr, err = getProcessAttr(pb.pluginProcConfig.User, pb.pluginProcConfig.Group)
 		if err != nil {
 			return nil, errors.Wrap(err, "while getting process attributes")
@@ -360,9 +362,6 @@ func setRequiredFields(pluginCfg *PluginConfig) {
 }
 
 func pluginIsValid(path string) error {
-	if testMode {
-		return nil
-	}
 	var details fs.FileInfo
 	var err error
 
@@ -387,7 +386,6 @@ func pluginIsValid(path string) error {
 
 	mode := details.Mode()
 	perm := uint32(mode)
-
 	if (perm & 00002) != 0 {
 		return fmt.Errorf("plugin at %s is world writable, world writable plugins are invalid", path)
 	}

+ 191 - 5
pkg/csplugin/broker_test.go

@@ -1,17 +1,36 @@
 package csplugin
 
 import (
-	"io/ioutil"
 	"log"
 	"os"
+	"os/exec"
 	"path"
 	"reflect"
 	"testing"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/pkg/errors"
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/tomb.v2"
 )
 
 var testPath string
 
-func Test_getPluginNameAndTypeFromPath(t *testing.T) {
+func setPluginPermTo744() {
+	setPluginPermTo("744")
+}
+
+func setPluginPermTo722() {
+	setPluginPermTo("722")
+}
+
+func setPluginPermTo724() {
+	setPluginPermTo("724")
+}
+func TestGetPluginNameAndTypeFromPath(t *testing.T) {
 	setUp()
 	defer tearDown()
 	type args struct {
@@ -69,7 +88,7 @@ func Test_getPluginNameAndTypeFromPath(t *testing.T) {
 	}
 }
 
-func Test_listFilesAtPath(t *testing.T) {
+func TestListFilesAtPath(t *testing.T) {
 	setUp()
 	defer tearDown()
 	type args struct {
@@ -113,9 +132,176 @@ func Test_listFilesAtPath(t *testing.T) {
 	}
 }
 
+func TestBrokerInit(t *testing.T) {
+
+	tests := []struct {
+		name        string
+		action      func()
+		errContains string
+		wantErr     bool
+		procCfg     csconfig.PluginCfg
+	}{
+		{
+			name:    "valid config",
+			action:  setPluginPermTo744,
+			wantErr: false,
+		},
+		{
+			name:        "group writable binary",
+			wantErr:     true,
+			errContains: "notification-dummy is world writable",
+			action:      setPluginPermTo722,
+		},
+		{
+			name:        "group writable binary",
+			wantErr:     true,
+			errContains: "notification-dummy is group writable",
+			action:      setPluginPermTo724,
+		},
+		{
+			name:        "no plugin dir",
+			wantErr:     true,
+			errContains: "no such file or directory",
+			action:      tearDown,
+		},
+		{
+			name:        "no plugin binary",
+			wantErr:     true,
+			errContains: "binary for plugin dummy_default not found",
+			action: func() {
+				err := os.Remove(path.Join(testPath, "notification-dummy"))
+				if err != nil {
+					t.Fatal(err)
+				}
+			},
+		},
+		{
+			name:        "only specify user",
+			wantErr:     true,
+			errContains: "both plugin user and group must be set",
+			procCfg: csconfig.PluginCfg{
+				User: "123445555551122toto",
+			},
+			action: setPluginPermTo744,
+		},
+		{
+			name:        "only specify group",
+			wantErr:     true,
+			errContains: "both plugin user and group must be set",
+			procCfg: csconfig.PluginCfg{
+				Group: "123445555551122toto",
+			},
+			action: setPluginPermTo744,
+		},
+		{
+			name:        "Fails to run as root",
+			wantErr:     true,
+			errContains: "operation not permitted",
+			procCfg: csconfig.PluginCfg{
+				User:  "root",
+				Group: "root",
+			},
+			action: setPluginPermTo744,
+		},
+		{
+			name:        "Invalid user and group",
+			wantErr:     true,
+			errContains: "unknown user toto1234",
+			procCfg: csconfig.PluginCfg{
+				User:  "toto1234",
+				Group: "toto1234",
+			},
+			action: setPluginPermTo744,
+		},
+		{
+			name:        "Valid user and invalid group",
+			wantErr:     true,
+			errContains: "unknown group toto1234",
+			procCfg: csconfig.PluginCfg{
+				User:  "nobody",
+				Group: "toto1234",
+			},
+			action: setPluginPermTo744,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			defer tearDown()
+			buildDummyPlugin()
+			if test.action != nil {
+				test.action()
+			}
+			pb := PluginBroker{}
+			profiles := csconfig.NewDefaultConfig().API.Server.Profiles
+			profiles = append(profiles, &csconfig.ProfileCfg{
+				Notifications: []string{"dummy_default"},
+			})
+			err := pb.Init(&test.procCfg, profiles, &csconfig.ConfigurationPaths{
+				PluginDir:       testPath,
+				NotificationDir: "./tests/notifications",
+			})
+			defer pb.Kill()
+			if test.wantErr {
+				assert.ErrorContains(t, err, test.errContains)
+			} else {
+				assert.NoError(t, err)
+			}
+
+		})
+	}
+}
+
+func TestBrokerRun(t *testing.T) {
+	buildDummyPlugin()
+	setPluginPermTo744()
+	defer tearDown()
+	procCfg := csconfig.PluginCfg{}
+	pb := PluginBroker{}
+	profiles := csconfig.NewDefaultConfig().API.Server.Profiles
+	profiles = append(profiles, &csconfig.ProfileCfg{
+		Notifications: []string{"dummy_default"},
+	})
+	err := pb.Init(&procCfg, profiles, &csconfig.ConfigurationPaths{
+		PluginDir:       testPath,
+		NotificationDir: "./tests/notifications",
+	})
+	assert.NoError(t, err)
+	tomb := tomb.Tomb{}
+	go pb.Run(&tomb)
+	defer pb.Kill()
+
+	assert.NoFileExists(t, "./out")
+	defer os.Remove("./out")
+
+	pb.PluginChannel <- ProfileAlert{ProfileID: uint(0), Alert: &models.Alert{}}
+	pb.PluginChannel <- ProfileAlert{ProfileID: uint(0), Alert: &models.Alert{}}
+	time.Sleep(time.Second * 4)
+
+	assert.FileExists(t, "./out")
+	assert.Equal(t, types.GetLineCountForFile("./out"), 2)
+}
+
+func buildDummyPlugin() {
+	dir, err := os.MkdirTemp("./tests", "cs_plugin_test")
+	if err != nil {
+		log.Fatal(err)
+	}
+	cmd := exec.Command("go", "build", "-o", path.Join(dir, "notification-dummy"), "../../plugins/notifications/dummy/")
+	if err := cmd.Run(); err != nil {
+		log.Fatal(err)
+	}
+	testPath = dir
+}
+
+func setPluginPermTo(perm string) {
+	if err := exec.Command("chmod", perm, path.Join(testPath, "notification-dummy")).Run(); err != nil {
+		log.Fatal(errors.Wrapf(err, "chmod 744 %s", path.Join(testPath, "notification-dummy")))
+	}
+}
+
 func setUp() {
-	testMode = true
-	dir, err := ioutil.TempDir("./", "cs_plugin_test")
+	dir, err := os.MkdirTemp("./", "cs_plugin_test")
 	if err != nil {
 		log.Fatal(err)
 	}

+ 22 - 0
pkg/csplugin/tests/notifications/dummy.yaml

@@ -0,0 +1,22 @@
+type: dummy         # Don't change
+name: dummy_default # Must match the registered plugin in the profile
+
+# One of "trace", "debug", "info", "warn", "error", "off"
+log_level: info
+
+# group_wait:         # Time to wait collecting alerts before relaying a message to this plugin, eg "30s"
+# group_threshold:    # Amount of alerts that triggers a message before <group_wait> has expired, eg "10"
+# max_retry:          # Number of attempts to relay messages to plugins in case of error
+# timeout:            # Time to wait for response from the plugin before considering the attempt a failure, eg "10s"
+
+#-------------------------
+# plugin-specific options
+
+# The following template receives a list of models.Alert objects
+# The output goes in the logs and to a text file, if defined
+format: |
+  {{.|toJson}}
+
+#
+output_file: ./out        # notifications will be appended here. optional
+

+ 107 - 0
pkg/csplugin/watcher_test.go

@@ -0,0 +1,107 @@
+package csplugin
+
+import (
+	"context"
+	"log"
+	"testing"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	"gopkg.in/tomb.v2"
+	"gotest.tools/v3/assert"
+)
+
+var ctx = context.Background()
+
+func resetTestTomb(testTomb *tomb.Tomb) {
+	testTomb.Kill(nil)
+	if err := testTomb.Wait(); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func resetWatcherAlertCounter(pw *PluginWatcher) {
+	for k := range pw.AlertCountByPluginName {
+		pw.AlertCountByPluginName[k] = 0
+	}
+}
+
+func insertNAlertsToPlugin(pw *PluginWatcher, n int, pluginName string) {
+	for i := 0; i < n; i++ {
+		pw.Inserts <- pluginName
+	}
+}
+
+func listenChannelWithTimeout(ctx context.Context, channel chan string) error {
+	select {
+	case <-channel:
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+	return nil
+}
+
+func TestPluginWatcherInterval(t *testing.T) {
+	pw := PluginWatcher{}
+	alertsByPluginName := make(map[string][]*models.Alert)
+	testTomb := tomb.Tomb{}
+	configs := map[string]PluginConfig{
+		"testPlugin": {
+			GroupWait: time.Millisecond,
+		},
+	}
+	pw.Init(configs, alertsByPluginName)
+	pw.Start(&testTomb)
+
+	ct, cancel := context.WithTimeout(ctx, time.Microsecond)
+	defer cancel()
+	err := listenChannelWithTimeout(ct, pw.PluginEvents)
+	assert.ErrorContains(t, err, "context deadline exceeded")
+
+	resetTestTomb(&testTomb)
+	testTomb = tomb.Tomb{}
+	pw.Start(&testTomb)
+
+	ct, cancel = context.WithTimeout(ctx, time.Millisecond*5)
+	defer cancel()
+	err = listenChannelWithTimeout(ct, pw.PluginEvents)
+	assert.NilError(t, err)
+	resetTestTomb(&testTomb)
+	// This is to avoid the int complaining
+}
+
+func TestPluginAlertCountWatcher(t *testing.T) {
+	pw := PluginWatcher{}
+	alertsByPluginName := make(map[string][]*models.Alert)
+	configs := map[string]PluginConfig{
+		"testPlugin": {
+			GroupThreshold: 5,
+		},
+	}
+	testTomb := tomb.Tomb{}
+	pw.Init(configs, alertsByPluginName)
+	pw.Start(&testTomb)
+
+	// Channel won't contain any events since threshold is not crossed.
+	ct, cancel := context.WithTimeout(ctx, time.Second)
+	defer cancel()
+	err := listenChannelWithTimeout(ct, pw.PluginEvents)
+	assert.ErrorContains(t, err, "context deadline exceeded")
+
+	// Channel won't contain any events since threshold is not crossed.
+	resetWatcherAlertCounter(&pw)
+	insertNAlertsToPlugin(&pw, 4, "testPlugin")
+	ct, cancel = context.WithTimeout(ctx, time.Second)
+	defer cancel()
+	err = listenChannelWithTimeout(ct, pw.PluginEvents)
+	assert.ErrorContains(t, err, "context deadline exceeded")
+
+	// Channel will contain an event since threshold is crossed.
+	resetWatcherAlertCounter(&pw)
+	insertNAlertsToPlugin(&pw, 5, "testPlugin")
+	ct, cancel = context.WithTimeout(ctx, time.Second)
+	defer cancel()
+	err = listenChannelWithTimeout(ct, pw.PluginEvents)
+	assert.NilError(t, err)
+	resetTestTomb(&testTomb)
+}

+ 3 - 1
pkg/database/decisions.go

@@ -408,7 +408,7 @@ func (c *Client) DeleteDecisionsWithFilter(filter map[string][]string) (string,
 	return strconv.Itoa(nbDeleted), nil
 }
 
-// SoftDeleteDecisionsWithFilter udpate the expiration time to now() for the decisions matching the filter
+// SoftDeleteDecisionsWithFilter updates the expiration time to now() for the decisions matching the filter
 func (c *Client) SoftDeleteDecisionsWithFilter(filter map[string][]string) (string, error) {
 	var err error
 	var start_ip, start_sfx, end_ip, end_sfx int64
@@ -426,6 +426,8 @@ func (c *Client) SoftDeleteDecisionsWithFilter(filter map[string][]string) (stri
 			}
 		case "scopes":
 			decisions = decisions.Where(decision.ScopeEQ(value[0]))
+		case "origin":
+			decisions = decisions.Where(decision.OriginEQ(value[0]))
 		case "value":
 			decisions = decisions.Where(decision.ValueEQ(value[0]))
 		case "type":

+ 40 - 0
pkg/types/dataset_test.go

@@ -0,0 +1,40 @@
+package types
+
+import (
+	"io/ioutil"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/jarcoal/httpmock"
+)
+
+func TestDownladFile(t *testing.T) {
+	httpmock.Activate()
+	defer httpmock.DeactivateAndReset()
+	//OK
+	httpmock.RegisterResponder(
+		"GET",
+		"https://example.com/xx",
+		httpmock.NewStringResponder(200, "example content oneoneone"),
+	)
+	httpmock.RegisterResponder(
+		"GET",
+		"https://example.com/x",
+		httpmock.NewStringResponder(404, "not found"),
+	)
+	err := downloadFile("https://example.com/xx", "./example.txt")
+	assert.NoError(t, err)
+	content, err := ioutil.ReadFile("./example.txt")
+	assert.Equal(t, "example content oneoneone", string(content))
+	assert.NoError(t, err)
+	//bad uri
+	err = downloadFile("https://zz.com", "./example.txt")
+	assert.Error(t, err)
+	//404
+	err = downloadFile("https://example.com/x", "./example.txt")
+	assert.Error(t, err)
+	//bad target
+	err = downloadFile("https://example.com/xx", "")
+	assert.Error(t, err)
+}

+ 15 - 0
pkg/types/utils.go

@@ -1,6 +1,7 @@
 package types
 
 import (
+	"bufio"
 	"bytes"
 	"encoding/gob"
 	"fmt"
@@ -243,3 +244,17 @@ func InSlice(str string, slice []string) bool {
 func UtcNow() time.Time {
 	return time.Now().UTC()
 }
+
+func GetLineCountForFile(filepath string) int {
+	f, err := os.Open(filepath)
+	if err != nil {
+		log.Fatalf("unable to open log file %s", filepath)
+	}
+	defer f.Close()
+	lc := 0
+	fs := bufio.NewScanner(f)
+	for fs.Scan() {
+		lc++
+	}
+	return lc
+}

+ 2 - 2
plugins/notifications/dummy/main.go

@@ -46,7 +46,7 @@ func (s *DummyPlugin) Notify(ctx context.Context, notification *protobufs.Notifi
 		if err != nil {
 			logger.Error(fmt.Sprintf("Cannot open notification file: %s", err))
 		}
-		if _, err := f.WriteString(notification.Text); err != nil {
+		if _, err := f.WriteString(notification.Text + "\n"); err != nil {
 			f.Close()
 			logger.Error(fmt.Sprintf("Cannot write notification to file: %s", err))
 		}
@@ -55,7 +55,7 @@ func (s *DummyPlugin) Notify(ctx context.Context, notification *protobufs.Notifi
 			logger.Error(fmt.Sprintf("Cannot close notification file: %s", err))
 		}
 	}
-	fmt.Print(notification.Text)
+	fmt.Println(notification.Text)
 
 	return &protobufs.Empty{}, nil
 }

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff