Преглед на файлове

Apiclient tests (#484)

Co-authored-by: AlteredCoder
Co-authored-by: erenJag
Thibault "bui" Koechlin преди 4 години
родител
ревизия
71ac0d2fce

+ 0 - 0
docs/v1.X/docs/contributing.md → docs/contributing.md


+ 0 - 30
docs/v0.3.X/docs/contributing.md

@@ -1,30 +0,0 @@
-# Contributing
-
-You have an idea, a suggestion or you spotted a mistake ?
-Help us improve the software and the user experience, to make the internet a safer place together !
-
-
-
-## Contributing to the documentation
-
-If you spotted some mistakes in the documentation or have improvement suggestions, you can :
-
- - open a {{v0X.doc.new_issue}} if you are comfortable with github
- - let us know on {{v0X.doc.discourse}} if you want to discuss about it
-
-Let us as well know if you have some improvement suggestions !
-
-
-
-## Contributing to the code
-
- - If you want to report a bug, you can use [the github bugtracker]({{v0X.crowdsec.bugreport}})
- - If you want to suggest an improvement you can use either [the github bugtracker]({{v0X.crowdsec.bugreport}}) or the {{v0X.doc.discourse}} if you want to discuss 
-
-
-## Contributing to the parsers/scenarios
-
-If you want to contribute your parser or scenario to the community and have them appear on the {{v0X.hub.htmlname}}, you should [open a merge request](https://github.com/crowdsecurity/hub/pulls) on the hub.
-
-We are currently working on a proper [CI](https://en.wikipedia.org/wiki/Continuous_integration) for the {{v0X.hub.htmlname}}, so for now all contribution are subject to peer-review, please bear with us !
-

+ 0 - 1
docs/v0.3.X/mkdocs.yml

@@ -37,7 +37,6 @@ nav:
     - Expressions: write_configurations/expressions.md
   - bouncers: bouncers/index.md
   - Contributing: 
-    - General: contributing.md
     - Writing Output Plugins: references/plugins_api.md
   - Cscli commands:
     - Cscli: cscli/cscli.md

+ 0 - 3
docs/v1.X/docs/about.md

@@ -1,3 +0,0 @@
-# Crowdsec
-
-{{macros_info() }}

+ 0 - 5
docs/v1.X/mkdocs.yml

@@ -75,10 +75,5 @@ nav:
   - Admin Guide:
     - Services Configuration: admin_guide/services_configuration.md
     - Architecture: admin_guide/architecture.md
-  - Contributing: 
-    - General: contributing.md
-    - Reporting bugs: contributing.md
-    - Asking questions: contributing.md
-    - Publishing parsers & scenarios: contributing.md
   - Upgrade V0.X to V1.X: migration.md
 

+ 2 - 1
mkdocs.yml

@@ -6,6 +6,7 @@ nav:
   - Developers : https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI" target="_blank
   - Hub : https://hub.crowdsec.net/" target="_blank
   - Releases : https://github.com/crowdsecurity/crowdsec/releases" target="_blank
+  - Contributing: contributing.md
   - FAQ: faq.md
 
 
@@ -42,7 +43,7 @@ google_analytics:
     - auto
 
 extra:
-    swagger_url: "https://raw.githubusercontent.com/crowdsecurity/crowdsec/wip_lapi/pkg/models/localapi_swagger.yaml"
+    swagger_url: "https://raw.githubusercontent.com/crowdsecurity/crowdsec/master/pkg/models/localapi_swagger.yaml"
     v0X:
       doc:
           new_issue: "[new documentation issue](https://github.com/crowdsecurity/crowdsec/issues/new)"

+ 5 - 4
pkg/apiclient/alerts_service.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	qs "github.com/google/go-querystring/query"
+	"github.com/pkg/errors"
 )
 
 // type ApiAlerts service
@@ -65,7 +66,7 @@ func (s *AlertsService) List(ctx context.Context, opts AlertsListOpts) (*models.
 	u := fmt.Sprintf("%s/alerts", s.client.URLPrefix)
 	params, err := qs.Values(opts)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, errors.Wrap(err, "building query")
 	}
 	if len(params) > 0 {
 		URI = fmt.Sprintf("%s?%s", u, params.Encode())
@@ -75,12 +76,12 @@ func (s *AlertsService) List(ctx context.Context, opts AlertsListOpts) (*models.
 
 	req, err := s.client.NewRequest("GET", URI, nil)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, errors.Wrap(err, "building request")
 	}
 
 	resp, err := s.client.Do(ctx, req, &alerts)
 	if err != nil {
-		return nil, resp, err
+		return nil, resp, errors.Wrap(err, "performing request")
 	}
 	return &alerts, resp, nil
 }
@@ -117,7 +118,7 @@ func (s *AlertsService) GetByID(ctx context.Context, alertID int) (*models.Alert
 
 	resp, err := s.client.Do(ctx, req, &alert)
 	if err != nil {
-		return nil, resp, err
+		return nil, nil, err
 	}
 	return &alert, resp, nil
 }

+ 496 - 0
pkg/apiclient/alerts_service_test.go

@@ -0,0 +1,496 @@
+package apiclient
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"reflect"
+	"testing"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAlertsListAsMachine(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	defer teardown()
+
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+
+		if r.URL.RawQuery == "ip=1.2.3.4" {
+			testMethod(t, r, "GET")
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprintf(w, `null`)
+			return
+		}
+
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprint(w, `[
+			{"capacity":5,"created_at":"2020-11-28T10:20:47+01:00",
+			 "decisions":[
+				  {"duration":"59m49.264032632s",
+				  "end_ip":16843180,
+				  "id":1,
+				  "origin":"crowdsec",
+				  "scenario":"crowdsecurity/ssh-bf",
+				  "scope":"Ip",
+				  "simulated":false,
+				  "start_ip":16843180,
+				  "type":"ban",
+				  "value":"1.1.1.172"}
+				  ],
+			 "events":[
+				 {"meta":[
+					  {"key":"target_user","value":"netflix"},
+					  {"key":"service","value":"ssh"}
+					],
+					"timestamp":"2020-11-28 10:20:46 +0000 UTC"},
+				 {"meta":[
+					 {"key":"target_user","value":"netflix"},
+					 {"key":"service","value":"ssh"}
+					 ],
+					 "timestamp":"2020-11-28 10:20:46 +0000 UTC"}
+				],
+				"events_count":6,
+				"id":1,
+				"labels":null,
+				"leakspeed":"10s",
+				"machine_id":"test",
+				"message":"Ip 1.1.1.172 performed 'crowdsecurity/ssh-bf' (6 events over 2.920062ms) at 2020-11-28 10:20:46.845619968 +0100 CET m=+5.903899761",
+				"scenario":"crowdsecurity/ssh-bf",
+				"scenario_hash":"4441dcff07020f6690d998b7101e642359ba405c2abb83565bbbdcee36de280f",
+				"scenario_version":"0.1",
+				"simulated":false,
+				"source":{
+					"as_name":"Cloudflare Inc",
+					"cn":"AU",
+					"ip":"1.1.1.172",
+					"latitude":-37.7,
+					"longitude":145.1833,
+					"range":"1.1.1.0/24",
+					"scope":"Ip",
+					"value":"1.1.1.172"
+					},
+				"start_at":"2020-11-28 10:20:46.842701127 +0100 +0100",
+				"stop_at":"2020-11-28 10:20:46.845621385 +0100 +0100"
+			}
+		]`)
+	})
+
+	tcapacity := int32(5)
+	tduration := "59m49.264032632s"
+	torigin := "crowdsec"
+	tscenario := "crowdsecurity/ssh-bf"
+	tscope := "Ip"
+	ttype := "ban"
+	tvalue := "1.1.1.172"
+	ttimestamp := "2020-11-28 10:20:46 +0000 UTC"
+	teventscount := int32(6)
+	tleakspeed := "10s"
+	tmessage := "Ip 1.1.1.172 performed 'crowdsecurity/ssh-bf' (6 events over 2.920062ms) at 2020-11-28 10:20:46.845619968 +0100 CET m=+5.903899761"
+	tscenariohash := "4441dcff07020f6690d998b7101e642359ba405c2abb83565bbbdcee36de280f"
+	tscenarioversion := "0.1"
+	tstartat := "2020-11-28 10:20:46.842701127 +0100 +0100"
+	tstopat := "2020-11-28 10:20:46.845621385 +0100 +0100"
+
+	expected := models.GetAlertsResponse{
+		&models.Alert{
+			Capacity:  &tcapacity,
+			CreatedAt: "2020-11-28T10:20:47+01:00",
+			Decisions: []*models.Decision{
+				&models.Decision{
+					Duration: &tduration,
+					EndIP:    16843180,
+					ID:       1,
+					Origin:   &torigin,
+					Scenario: &tscenario,
+
+					Scope:     &tscope,
+					Simulated: new(bool), //false,
+					StartIP:   16843180,
+					Type:      &ttype,
+					Value:     &tvalue,
+				},
+			},
+			Events: []*models.Event{
+				&models.Event{
+					Meta: models.Meta{
+						&models.MetaItems0{
+							Key:   "target_user",
+							Value: "netflix",
+						},
+						&models.MetaItems0{
+							Key:   "service",
+							Value: "ssh",
+						},
+					},
+					Timestamp: &ttimestamp,
+				},
+				&models.Event{
+					Meta: models.Meta{
+						&models.MetaItems0{
+							Key:   "target_user",
+							Value: "netflix",
+						},
+						&models.MetaItems0{
+							Key:   "service",
+							Value: "ssh",
+						},
+					},
+					Timestamp: &ttimestamp,
+				},
+			},
+			EventsCount:     &teventscount,
+			ID:              1,
+			Leakspeed:       &tleakspeed,
+			MachineID:       "test",
+			Message:         &tmessage,
+			Remediation:     false,
+			Scenario:        &tscenario,
+			ScenarioHash:    &tscenariohash,
+			ScenarioVersion: &tscenarioversion,
+			Simulated:       new(bool), //(false),
+			Source: &models.Source{
+				AsName:    "Cloudflare Inc",
+				AsNumber:  "",
+				Cn:        "AU",
+				IP:        "1.1.1.172",
+				Latitude:  -37.7,
+				Longitude: 145.1833,
+				Range:     "1.1.1.0/24",
+				Scope:     &tscope,
+				Value:     &tvalue,
+			},
+			StartAt: &tstartat,
+			StopAt:  &tstopat,
+		},
+	}
+
+	//log.Debugf("data : -> %s", spew.Sdump(alerts))
+	//log.Debugf("resp : -> %s", spew.Sdump(resp))
+	//log.Debugf("expected : -> %s", spew.Sdump(expected))
+	//first one returns data
+	alerts, resp, err := client.Alerts.List(context.Background(), AlertsListOpts{})
+	if err != nil {
+		log.Errorf("test Unable to list alerts : %+v", err)
+	}
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+
+	if !reflect.DeepEqual(*alerts, expected) {
+		t.Errorf("client.Alerts.List returned %+v, want %+v", resp, expected)
+	}
+	//this one doesn't
+	filter := AlertsListOpts{IPEquals: new(string)}
+	*filter.IPEquals = "1.2.3.4"
+	alerts, resp, err = client.Alerts.List(context.Background(), filter)
+	if err != nil {
+		log.Errorf("test Unable to list alerts : %+v", err)
+	}
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	assert.Equal(t, 0, len(*alerts))
+}
+
+func TestAlertsGetAsMachine(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	defer teardown()
+	mux.HandleFunc("/alerts/2", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusNotFound)
+		fmt.Fprintf(w, `{"message":"object not found"}`)
+	})
+
+	mux.HandleFunc("/alerts/1", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprint(w, `{"capacity":5,"created_at":"2020-11-28T10:20:47+01:00",
+			 "decisions":[
+				  {"duration":"59m49.264032632s",
+				  "end_ip":16843180,
+				  "id":1,
+				  "origin":"crowdsec",
+				  "scenario":"crowdsecurity/ssh-bf",
+				  "scope":"Ip",
+				  "simulated":false,
+				  "start_ip":16843180,
+				  "type":"ban",
+				  "value":"1.1.1.172"}
+				  ],
+			 "events":[
+				 {"meta":[
+					  {"key":"target_user","value":"netflix"},
+					  {"key":"service","value":"ssh"}
+					],
+					"timestamp":"2020-11-28 10:20:46 +0000 UTC"},
+				 {"meta":[
+					 {"key":"target_user","value":"netflix"},
+					 {"key":"service","value":"ssh"}
+					 ],
+					 "timestamp":"2020-11-28 10:20:46 +0000 UTC"}
+				],
+				"events_count":6,
+				"id":1,
+				"labels":null,
+				"leakspeed":"10s",
+				"machine_id":"test",
+				"message":"Ip 1.1.1.172 performed 'crowdsecurity/ssh-bf' (6 events over 2.920062ms) at 2020-11-28 10:20:46.845619968 +0100 CET m=+5.903899761",
+				"scenario":"crowdsecurity/ssh-bf",
+				"scenario_hash":"4441dcff07020f6690d998b7101e642359ba405c2abb83565bbbdcee36de280f",
+				"scenario_version":"0.1",
+				"simulated":false,
+				"source":{
+					"as_name":"Cloudflare Inc",
+					"cn":"AU",
+					"ip":"1.1.1.172",
+					"latitude":-37.7,
+					"longitude":145.1833,
+					"range":"1.1.1.0/24",
+					"scope":"Ip",
+					"value":"1.1.1.172"
+					},
+				"start_at":"2020-11-28 10:20:46.842701127 +0100 +0100",
+				"stop_at":"2020-11-28 10:20:46.845621385 +0100 +0100"
+			}`)
+	})
+
+	tcapacity := int32(5)
+	tduration := "59m49.264032632s"
+	torigin := "crowdsec"
+	tscenario := "crowdsecurity/ssh-bf"
+	tscope := "Ip"
+	ttype := "ban"
+	tvalue := "1.1.1.172"
+	ttimestamp := "2020-11-28 10:20:46 +0000 UTC"
+	teventscount := int32(6)
+	tleakspeed := "10s"
+	tmessage := "Ip 1.1.1.172 performed 'crowdsecurity/ssh-bf' (6 events over 2.920062ms) at 2020-11-28 10:20:46.845619968 +0100 CET m=+5.903899761"
+	tscenariohash := "4441dcff07020f6690d998b7101e642359ba405c2abb83565bbbdcee36de280f"
+	tscenarioversion := "0.1"
+	tstartat := "2020-11-28 10:20:46.842701127 +0100 +0100"
+	tstopat := "2020-11-28 10:20:46.845621385 +0100 +0100"
+
+	expected := &models.Alert{
+		Capacity:  &tcapacity,
+		CreatedAt: "2020-11-28T10:20:47+01:00",
+		Decisions: []*models.Decision{
+			&models.Decision{
+				Duration: &tduration,
+				EndIP:    16843180,
+				ID:       1,
+				Origin:   &torigin,
+				Scenario: &tscenario,
+
+				Scope:     &tscope,
+				Simulated: new(bool), //false,
+				StartIP:   16843180,
+				Type:      &ttype,
+				Value:     &tvalue,
+			},
+		},
+		Events: []*models.Event{
+			&models.Event{
+				Meta: models.Meta{
+					&models.MetaItems0{
+						Key:   "target_user",
+						Value: "netflix",
+					},
+					&models.MetaItems0{
+						Key:   "service",
+						Value: "ssh",
+					},
+				},
+				Timestamp: &ttimestamp,
+			},
+			&models.Event{
+				Meta: models.Meta{
+					&models.MetaItems0{
+						Key:   "target_user",
+						Value: "netflix",
+					},
+					&models.MetaItems0{
+						Key:   "service",
+						Value: "ssh",
+					},
+				},
+				Timestamp: &ttimestamp,
+			},
+		},
+		EventsCount:     &teventscount,
+		ID:              1,
+		Leakspeed:       &tleakspeed,
+		MachineID:       "test",
+		Message:         &tmessage,
+		Remediation:     false,
+		Scenario:        &tscenario,
+		ScenarioHash:    &tscenariohash,
+		ScenarioVersion: &tscenarioversion,
+		Simulated:       new(bool), //(false),
+		Source: &models.Source{
+			AsName:    "Cloudflare Inc",
+			AsNumber:  "",
+			Cn:        "AU",
+			IP:        "1.1.1.172",
+			Latitude:  -37.7,
+			Longitude: 145.1833,
+			Range:     "1.1.1.0/24",
+			Scope:     &tscope,
+			Value:     &tvalue,
+		},
+		StartAt: &tstartat,
+		StopAt:  &tstopat,
+	}
+
+	alerts, resp, err := client.Alerts.GetByID(context.Background(), 1)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+
+	if !reflect.DeepEqual(*alerts, *expected) {
+		t.Errorf("client.Alerts.List returned %+v, want %+v", resp, expected)
+	}
+
+	//fail
+	alerts, resp, err = client.Alerts.GetByID(context.Background(), 2)
+	assert.Contains(t, fmt.Sprintf("%s", err), "API error: object not found")
+
+}
+
+func TestAlertsCreateAsMachine(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`["3"]`))
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	defer teardown()
+	alert := models.AddAlertsRequest{}
+	alerts, resp, err := client.Alerts.Add(context.Background(), alert)
+	expected := &models.AddAlertsResponse{"3"}
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	if !reflect.DeepEqual(*alerts, *expected) {
+		t.Errorf("client.Alerts.List returned %+v, want %+v", resp, expected)
+	}
+}
+
+func TestAlertsDeleteAsMachine(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+		assert.Equal(t, r.URL.RawQuery, "ip=1.2.3.4")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"message":"0 deleted alerts"}`))
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	defer teardown()
+	alert := AlertsDeleteOpts{IPEquals: new(string)}
+	*alert.IPEquals = "1.2.3.4"
+	alerts, resp, err := client.Alerts.Delete(context.Background(), alert)
+	expected := &models.DeleteAlertsResponse{""}
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	if !reflect.DeepEqual(*alerts, *expected) {
+		t.Errorf("client.Alerts.List returned %+v, want %+v", resp, expected)
+	}
+}

+ 2 - 2
pkg/apiclient/auth.go

@@ -32,7 +32,7 @@ type APIKeyTransport struct {
 // RoundTrip implements the RoundTripper interface.
 func (t *APIKeyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 	if t.APIKey == "" {
-		return nil, errors.New("t.APIKey is empty")
+		return nil, errors.New("APIKey is empty")
 	}
 
 	// We must make a copy of the Request so
@@ -97,7 +97,7 @@ func (t *JWTTransport) refreshJwtToken() error {
 		if err != nil {
 			return fmt.Errorf("can't update scenario list: %s", err)
 		}
-		log.Infof("scenarios liste updated for '%s'", *t.MachineID)
+		log.Infof("scenarios list updated for '%s'", *t.MachineID)
 	}
 
 	var auth = models.WatcherAuthRequest{

+ 181 - 0
pkg/apiclient/auth_service_test.go

@@ -0,0 +1,181 @@
+package apiclient
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWatcherAuth(t *testing.T) {
+
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	defer teardown()
+	//body: models.WatcherRegistrationRequest{MachineID: &config.MachineID, Password: &config.Password}
+
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		buf := new(bytes.Buffer)
+		_, _ = buf.ReadFrom(r.Body)
+		newStr := buf.String()
+		log.Printf("--> %s", newStr)
+		if newStr == `{"machine_id":"test_login","password":"test_password","scenarios":["crowdsecurity/test"]}
+` {
+			log.Printf("ok cool")
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprintf(w, `{"code":200,"expire":"2029-11-30T14:14:24+01:00","token":"toto"}`)
+		} else {
+			w.WriteHeader(http.StatusForbidden)
+			log.Printf("badbad")
+			fmt.Fprintf(w, `{"message":"access forbidden"}`)
+		}
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+
+	//ok auth
+	mycfg := &Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+		Scenarios:     []string{"crowdsecurity/test"},
+	}
+	client, err := NewClient(mycfg)
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	_, err = client.Auth.AuthenticateWatcher(context.Background(), models.WatcherAuthRequest{
+		MachineID: &mycfg.MachineID,
+		Password:  &mycfg.Password,
+		Scenarios: mycfg.Scenarios,
+	})
+	if err != nil {
+		t.Fatalf("unexpect auth err 0: %s", err)
+	}
+
+	//bad auth
+	mycfg = &Config{
+		MachineID:     "BADtest_login",
+		Password:      "BADtest_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+		Scenarios:     []string{"crowdsecurity/test"},
+	}
+	client, err = NewClient(mycfg)
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	_, err = client.Auth.AuthenticateWatcher(context.Background(), models.WatcherAuthRequest{
+		MachineID: &mycfg.MachineID,
+		Password:  &mycfg.Password,
+	})
+	assert.Contains(t, err.Error(), "403 Forbidden")
+
+}
+
+func TestWatcherRegister(t *testing.T) {
+
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	defer teardown()
+	//body: models.WatcherRegistrationRequest{MachineID: &config.MachineID, Password: &config.Password}
+
+	mux.HandleFunc("/watchers", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		buf := new(bytes.Buffer)
+		_, _ = buf.ReadFrom(r.Body)
+		newStr := buf.String()
+		assert.Equal(t, newStr, `{"machine_id":"test_login","password":"test_password"}
+`)
+		w.WriteHeader(http.StatusOK)
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := RegisterClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	}, &http.Client{})
+	if err != nil {
+		t.Fatalf("while registering client : %s", err)
+	}
+	log.Printf("->%T", client)
+}
+
+func TestWatcherUnregister(t *testing.T) {
+
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	defer teardown()
+	//body: models.WatcherRegistrationRequest{MachineID: &config.MachineID, Password: &config.Password}
+
+	mux.HandleFunc("/watchers", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+		assert.Equal(t, r.ContentLength, int64(0))
+		w.WriteHeader(http.StatusOK)
+	})
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		buf := new(bytes.Buffer)
+		_, _ = buf.ReadFrom(r.Body)
+		newStr := buf.String()
+		if newStr == `{"machine_id":"test_login","password":"test_password","scenarios":["crowdsecurity/test"]}
+` {
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprintf(w, `{"code":200,"expire":"2029-11-30T14:14:24+01:00","token":"toto"}`)
+		} else {
+			w.WriteHeader(http.StatusForbidden)
+			fmt.Fprintf(w, `{"message":"access forbidden"}`)
+		}
+	})
+
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	mycfg := &Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+		Scenarios:     []string{"crowdsecurity/test"},
+	}
+	client, err := NewClient(mycfg)
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+	_, err = client.Auth.UnregisterWatcher(context.Background())
+	if err != nil {
+		t.Fatalf("while registering client : %s", err)
+	}
+	log.Printf("->%T", client)
+}

+ 83 - 0
pkg/apiclient/auth_test.go

@@ -0,0 +1,83 @@
+package apiclient
+
+import (
+	"context"
+	"net/http"
+	"net/url"
+	"testing"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestApiAuth(t *testing.T) {
+	log.SetLevel(log.TraceLevel)
+
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/decisions", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		if r.Header.Get("X-Api-Key") == "ixu" {
+			assert.Equal(t, r.URL.RawQuery, "ip=1.2.3.4")
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte(`null`))
+		} else {
+			w.WriteHeader(http.StatusForbidden)
+			w.Write([]byte(`{"message":"access forbidden"}`))
+		}
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+
+	defer teardown()
+
+	//ok no answer
+	auth := &APIKeyTransport{
+		APIKey: "ixu",
+	}
+
+	newcli, err := NewDefaultClient(apiURL, "v1", "toto", auth.Client())
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	alert := DecisionsListOpts{IPEquals: new(string)}
+	*alert.IPEquals = "1.2.3.4"
+	_, resp, err := newcli.Decisions.List(context.Background(), alert)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+
+	//ko bad token
+	auth = &APIKeyTransport{
+		APIKey: "bad",
+	}
+
+	newcli, err = NewDefaultClient(apiURL, "v1", "toto", auth.Client())
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	_, resp, err = newcli.Decisions.List(context.Background(), alert)
+
+	log.Infof("--> %s", err)
+	if resp.Response.StatusCode != http.StatusForbidden {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	assert.Contains(t, err.Error(), "API error: access forbidden")
+	//ko empty token
+	auth = &APIKeyTransport{}
+	newcli, err = NewDefaultClient(apiURL, "v1", "toto", auth.Client())
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	_, resp, err = newcli.Decisions.List(context.Background(), alert)
+
+	log.Infof("--> %s", err)
+	assert.Contains(t, err.Error(), "APIKey is empty")
+
+}

+ 18 - 5
pkg/apiclient/client.go

@@ -86,11 +86,14 @@ func RegisterClient(config *Config, client *http.Client) (*ApiClient, error) {
 	c.Alerts = (*AlertsService)(&c.common)
 	c.Auth = (*AuthService)(&c.common)
 
-	_, err := c.Auth.RegisterWatcher(context.Background(), models.WatcherRegistrationRequest{MachineID: &config.MachineID, Password: &config.Password})
+	resp, err := c.Auth.RegisterWatcher(context.Background(), models.WatcherRegistrationRequest{MachineID: &config.MachineID, Password: &config.Password})
+	/*if we have http status, return it*/
 	if err != nil {
-		return c, errors.Wrapf(err, "api register (%s): %s", c.BaseURL, err)
+		if resp != nil && resp.Response != nil {
+			return nil, errors.Wrapf(err, "api register (%s) http %s : %s", c.BaseURL, resp.Response.Status, err)
+		}
+		return nil, errors.Wrapf(err, "api register (%s) : %s", c.BaseURL, err)
 	}
-
 	return c, nil
 
 }
@@ -107,7 +110,11 @@ type ErrorResponse struct {
 }
 
 func (e *ErrorResponse) Error() string {
-	return fmt.Sprintf("API error (%s) : %s", *e.Message, e.Errors)
+	err := fmt.Sprintf("API error: %s", *e.Message)
+	if len(e.Errors) > 0 {
+		err += fmt.Sprintf(" (%s)", e.Errors)
+	}
+	return err
 }
 
 func newResponse(r *http.Response) *Response {
@@ -123,7 +130,13 @@ func CheckResponse(r *http.Response) error {
 	errorResponse := &ErrorResponse{}
 	data, err := ioutil.ReadAll(r.Body)
 	if err == nil && data != nil {
-		json.Unmarshal(data, errorResponse)
+		err := json.Unmarshal(data, errorResponse)
+		if err != nil {
+			return errors.Wrapf(err, "http code %d, invalid body", r.StatusCode)
+		}
+	} else {
+		errorResponse.Message = new(string)
+		*errorResponse.Message = fmt.Sprintf("http code %d, no error message", r.StatusCode)
 	}
 	return errorResponse
 }

+ 4 - 4
pkg/apiclient/client_http.go

@@ -71,11 +71,12 @@ func (c *ApiClient) Do(ctx context.Context, req *http.Request, v interface{}) (*
 		if e, ok := err.(*url.Error); ok {
 			if url, err := url.Parse(e.URL); err == nil {
 				e.URL = url.String()
-				return nil, e
+				return newResponse(resp), e
+			} else {
+				return newResponse(resp), err
 			}
 		}
-
-		return nil, err
+		return newResponse(resp), err
 	}
 
 	response := newResponse(resp)
@@ -98,6 +99,5 @@ func (c *ApiClient) Do(ctx context.Context, req *http.Request, v interface{}) (*
 			}
 		}
 	}
-
 	return response, err
 }

+ 77 - 0
pkg/apiclient/client_http_test.go

@@ -0,0 +1,77 @@
+package apiclient
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewRequestInvalid(t *testing.T) {
+	mux, urlx, teardown := setup()
+	defer teardown()
+	//missing slash in uri
+	apiURL, err := url.Parse(urlx)
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+	if err != nil {
+		t.Fatalf("new api client: %s", err.Error())
+	}
+	/*mock login*/
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(`{"code": 401, "message" : "bad login/password"}`))
+	})
+
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusOK)
+	})
+
+	_, _, err = client.Alerts.List(context.Background(), AlertsListOpts{})
+	assert.Contains(t, err.Error(), `building request: BaseURL must have a trailing slash, but `)
+}
+
+func TestNewRequestTimeout(t *testing.T) {
+	mux, urlx, teardown := setup()
+	defer teardown()
+	//missing slash in uri
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+	if err != nil {
+		t.Fatalf("new api client: %s", err.Error())
+	}
+	/*mock login*/
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(2 * time.Second)
+	})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+
+	_, _, err = client.Alerts.List(ctx, AlertsListOpts{})
+	assert.Contains(t, err.Error(), `performing request: context deadline exceeded`)
+}

+ 200 - 0
pkg/apiclient/client_test.go

@@ -0,0 +1,200 @@
+package apiclient
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	log "github.com/sirupsen/logrus"
+)
+
+/*this is a ripoff of google/go-github approach :
+- setup a test http server along with a client that is configured to talk to test server
+- each test will then bind handler for the method(s) they want to try
+*/
+
+func setup() (mux *http.ServeMux, serverURL string, teardown func()) {
+	// mux is the HTTP request multiplexer used with the test server.
+	mux = http.NewServeMux()
+	baseURLPath := "/v1"
+
+	apiHandler := http.NewServeMux()
+	apiHandler.Handle(baseURLPath+"/", http.StripPrefix(baseURLPath, mux))
+
+	// server is a test HTTP server used to provide mock API responses.
+	server := httptest.NewServer(apiHandler)
+
+	return mux, server.URL, server.Close
+}
+
+func testMethod(t *testing.T, r *http.Request, want string) {
+	t.Helper()
+	if got := r.Method; got != want {
+		t.Errorf("Request method: %v, want %v", got, want)
+	}
+}
+
+func TestNewClientOk(t *testing.T) {
+	mux, urlx, teardown := setup()
+	defer teardown()
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+	if err != nil {
+		t.Fatalf("new api client: %s", err.Error())
+	}
+	/*mock login*/
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusOK)
+	})
+
+	_, resp, err := client.Alerts.List(context.Background(), AlertsListOpts{})
+	if err != nil {
+		t.Fatalf("test Unable to list alerts : %+v", err)
+	}
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Fatalf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusCreated)
+	}
+}
+
+func TestNewClientKo(t *testing.T) {
+	mux, urlx, teardown := setup()
+	defer teardown()
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+	if err != nil {
+		t.Fatalf("new api client: %s", err.Error())
+	}
+	/*mock login*/
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(`{"code": 401, "message" : "bad login/password"}`))
+	})
+
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		w.WriteHeader(http.StatusOK)
+	})
+
+	_, _, err = client.Alerts.List(context.Background(), AlertsListOpts{})
+	assert.Contains(t, err.Error(), `received response status "401 Unauthorized"`)
+	log.Printf("err-> %s", err)
+}
+
+func TestNewDefaultClient(t *testing.T) {
+	mux, urlx, teardown := setup()
+	defer teardown()
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewDefaultClient(apiURL, "/v1", "", nil)
+	if err != nil {
+		t.Fatalf("new api client: %s", err.Error())
+	}
+	mux.HandleFunc("/alerts", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(`{"code": 401, "message" : "brr"}`))
+	})
+	_, _, err = client.Alerts.List(context.Background(), AlertsListOpts{})
+	assert.Contains(t, err.Error(), `performing request: API error: brr`)
+	log.Printf("err-> %s", err)
+}
+
+func TestNewClientRegisterKO(t *testing.T) {
+	apiURL, err := url.Parse("http://127.0.0.1:4242/")
+	if err != nil {
+		t.Fatalf("parsing api url: %s", apiURL)
+	}
+	_, err = RegisterClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	}, &http.Client{})
+	assert.Contains(t, fmt.Sprintf("%s", err), "dial tcp 127.0.0.1:4242: connect: connection refused")
+}
+
+func TestNewClientRegisterOK(t *testing.T) {
+	log.SetLevel(log.TraceLevel)
+	mux, urlx, teardown := setup()
+	defer teardown()
+
+	/*mock login*/
+	mux.HandleFunc("/watchers", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		t.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := RegisterClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	}, &http.Client{})
+	if err != nil {
+		t.Fatalf("while registering client : %s", err)
+	}
+	log.Printf("->%T", client)
+}
+
+func TestNewClientBadAnswer(t *testing.T) {
+	log.SetLevel(log.TraceLevel)
+	mux, urlx, teardown := setup()
+	defer teardown()
+
+	/*mock login*/
+	mux.HandleFunc("/watchers", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "POST")
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte(`bad`))
+	})
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		t.Fatalf("parsing api url: %s", apiURL)
+	}
+	_, err = RegisterClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	}, &http.Client{})
+	assert.Contains(t, fmt.Sprintf("%s", err), `invalid body: invalid character 'b' looking for beginning of value`)
+}

+ 1 - 1
pkg/apiclient/decisions_service.go

@@ -35,7 +35,7 @@ func (s *DecisionsService) List(ctx context.Context, opts DecisionsListOpts) (*m
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("%s/decisions/?%s", s.client.URLPrefix, params.Encode())
+	u := fmt.Sprintf("%s/decisions?%s", s.client.URLPrefix, params.Encode())
 
 	req, err := s.client.NewRequest("GET", u, nil)
 	if err != nil {

+ 273 - 0
pkg/apiclient/decisions_service_test.go

@@ -0,0 +1,273 @@
+package apiclient
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"reflect"
+	"testing"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDecisionsList(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/decisions", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "GET")
+		if r.URL.RawQuery == "ip=1.2.3.4" {
+			assert.Equal(t, r.URL.RawQuery, "ip=1.2.3.4")
+			assert.Equal(t, r.Header.Get("X-Api-Key"), "ixu")
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte(`[{"duration":"3h59m55.756182786s","end_ip":16909060,"id":4,"origin":"cscli","scenario":"manual 'ban' from '82929df7ee394b73b81252fe3b4e50203yaT2u6nXiaN7Ix9'","scope":"Ip","start_ip":16909060,"type":"ban","value":"1.2.3.4"}]`))
+		} else {
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte(`null`))
+			//no results
+		}
+	})
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+
+	//ok answer
+	auth := &APIKeyTransport{
+		APIKey: "ixu",
+	}
+
+	newcli, err := NewDefaultClient(apiURL, "v1", "toto", auth.Client())
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	tduration := "3h59m55.756182786s"
+	torigin := "cscli"
+	tscenario := "manual 'ban' from '82929df7ee394b73b81252fe3b4e50203yaT2u6nXiaN7Ix9'"
+	tscope := "Ip"
+	ttype := "ban"
+	tvalue := "1.2.3.4"
+	expected := &models.GetDecisionsResponse{
+		&models.Decision{
+			Duration: &tduration,
+			EndIP:    16909060,
+			ID:       4,
+			Origin:   &torigin,
+			Scenario: &tscenario,
+			Scope:    &tscope,
+			StartIP:  16909060,
+			Type:     &ttype,
+			Value:    &tvalue,
+		},
+	}
+
+	//OK decisions
+	decisionsFilter := DecisionsListOpts{IPEquals: new(string)}
+	*decisionsFilter.IPEquals = "1.2.3.4"
+	decisions, resp, err := newcli.Decisions.List(context.Background(), decisionsFilter)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+	if !reflect.DeepEqual(*decisions, *expected) {
+		t.Fatalf("returned %+v, want %+v", resp, expected)
+	}
+
+	//Empty return
+	decisionsFilter = DecisionsListOpts{IPEquals: new(string)}
+	*decisionsFilter.IPEquals = "1.2.3.5"
+	decisions, resp, err = newcli.Decisions.List(context.Background(), decisionsFilter)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	assert.Equal(t, len(*decisions), 0)
+
+}
+
+func TestDecisionsStream(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+
+	mux, urlx, teardown := setup()
+	defer teardown()
+
+	mux.HandleFunc("/decisions/stream", func(w http.ResponseWriter, r *http.Request) {
+
+		assert.Equal(t, r.Header.Get("X-Api-Key"), "ixu")
+		testMethod(t, r, "GET")
+		if r.Method == "GET" {
+
+			if r.URL.RawQuery == "startup=true" {
+				w.WriteHeader(http.StatusOK)
+				w.Write([]byte(`{"deleted":null,"new":[{"duration":"3h59m55.756182786s","end_ip":16909060,"id":4,"origin":"cscli","scenario":"manual 'ban' from '82929df7ee394b73b81252fe3b4e50203yaT2u6nXiaN7Ix9'","scope":"Ip","start_ip":16909060,"type":"ban","value":"1.2.3.4"}]}`))
+			} else {
+				w.WriteHeader(http.StatusOK)
+				w.Write([]byte(`{"deleted":null,"new":null}`))
+			}
+		}
+	})
+	mux.HandleFunc("/decisions", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, r.Header.Get("X-Api-Key"), "ixu")
+		testMethod(t, r, "DELETE")
+		if r.Method == "DELETE" {
+			w.WriteHeader(http.StatusOK)
+		}
+	})
+
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+
+	//ok answer
+	auth := &APIKeyTransport{
+		APIKey: "ixu",
+	}
+
+	newcli, err := NewDefaultClient(apiURL, "v1", "toto", auth.Client())
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	tduration := "3h59m55.756182786s"
+	torigin := "cscli"
+	tscenario := "manual 'ban' from '82929df7ee394b73b81252fe3b4e50203yaT2u6nXiaN7Ix9'"
+	tscope := "Ip"
+	ttype := "ban"
+	tvalue := "1.2.3.4"
+	expected := &models.DecisionsStreamResponse{
+		New: models.GetDecisionsResponse{
+			&models.Decision{
+				Duration: &tduration,
+				EndIP:    16909060,
+				ID:       4,
+				Origin:   &torigin,
+				Scenario: &tscenario,
+				Scope:    &tscope,
+				StartIP:  16909060,
+				Type:     &ttype,
+				Value:    &tvalue,
+			},
+		},
+	}
+
+	decisions, resp, err := newcli.Decisions.GetStream(context.Background(), true)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+	if !reflect.DeepEqual(*decisions, *expected) {
+		t.Fatalf("returned %+v, want %+v", resp, expected)
+	}
+
+	//and second call, we get empty lists
+	decisions, resp, err = newcli.Decisions.GetStream(context.Background(), false)
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+	assert.Equal(t, 0, len(decisions.New))
+	assert.Equal(t, 0, len(decisions.Deleted))
+
+	//delete stream
+	resp, err = newcli.Decisions.StopStream(context.Background())
+
+	if resp.Response.StatusCode != http.StatusOK {
+		t.Errorf("Alerts.List returned status: %d, want %d", resp.Response.StatusCode, http.StatusOK)
+	}
+}
+
+func TestDeleteDecisions(t *testing.T) {
+	mux, urlx, teardown := setup()
+	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+	})
+	mux.HandleFunc("/decisions", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, "DELETE")
+		assert.Equal(t, r.URL.RawQuery, "ip=1.2.3.4")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"nbDeleted":"1"}`))
+		//w.Write([]byte(`{"message":"0 deleted alerts"}`))
+	})
+	log.Printf("URL is %s", urlx)
+	apiURL, err := url.Parse(urlx + "/")
+	if err != nil {
+		log.Fatalf("parsing api url: %s", apiURL)
+	}
+	client, err := NewClient(&Config{
+		MachineID:     "test_login",
+		Password:      "test_password",
+		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+		URL:           apiURL,
+		VersionPrefix: "v1",
+	})
+
+	if err != nil {
+		log.Fatalf("new api client: %s", err.Error())
+	}
+
+	filters := DecisionsDeleteOpts{IPEquals: new(string)}
+	*filters.IPEquals = "1.2.3.4"
+	deleted, _, err := client.Decisions.Delete(context.Background(), filters)
+	if err != nil {
+		t.Fatalf("unexpected err : %s", err)
+	}
+	assert.Equal(t, "1", deleted.NbDeleted)
+
+	defer teardown()
+}
+
+// func TestDeleteOneDecision(t *testing.T) {
+// 	mux, urlx, teardown := setup()
+// 	mux.HandleFunc("/watchers/login", func(w http.ResponseWriter, r *http.Request) {
+// 		w.WriteHeader(http.StatusOK)
+// 		w.Write([]byte(`{"code": 200, "expire": "2030-01-02T15:04:05Z", "token": "oklol"}`))
+// 	})
+// 	mux.HandleFunc("/decisions/1", func(w http.ResponseWriter, r *http.Request) {
+// 		testMethod(t, r, "DELETE")
+// 		w.WriteHeader(http.StatusOK)
+// 		w.Write([]byte(`{"nbDeleted":"1"}`))
+// 	})
+// 	log.Printf("URL is %s", urlx)
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		log.Fatalf("parsing api url: %s", apiURL)
+// 	}
+// 	client, err := NewClient(&Config{
+// 		MachineID:     "test_login",
+// 		Password:      "test_password",
+// 		UserAgent:     fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
+// 		URL:           apiURL,
+// 		VersionPrefix: "v1",
+// 	})
+
+// 	if err != nil {
+// 		log.Fatalf("new api client: %s", err.Error())
+// 	}
+
+// 	filters := DecisionsDeleteOpts{IPEquals: new(string)}
+// 	*filters.IPEquals = "1.2.3.4"
+// 	deleted, _, err := client.Decisions.Delete(context.Background(), filters)
+// 	if err != nil {
+// 		t.Fatalf("unexpected err : %s", err)
+// 	}
+// 	assert.Equal(t, "1", deleted.NbDeleted)
+
+// 	defer teardown()
+// }

+ 2 - 2
pkg/apiserver/alerts_test.go

@@ -130,7 +130,7 @@ func TestCreateAlert(t *testing.T) {
 	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", loginResp.Token))
 	router.ServeHTTP(w, req)
 
-	assert.Equal(t, 200, w.Code)
+	assert.Equal(t, 201, w.Code)
 	assert.Equal(t, "[\"1\"]", w.Body.String())
 }
 
@@ -538,5 +538,5 @@ func TestDeleteAlert(t *testing.T) {
 	req.RemoteAddr = "127.0.0.1:4242"
 	router.ServeHTTP(w, req)
 	assert.Equal(t, 200, w.Code)
-	assert.Equal(t, `{"message":"1 deleted alerts"}`, w.Body.String())
+	assert.Equal(t, `{"nbDeleted":"1"}`, w.Body.String())
 }

+ 0 - 1
pkg/apiserver/apiserver_test.go

@@ -150,6 +150,5 @@ func TestUnknownPath(t *testing.T) {
 	router.ServeHTTP(w, req)
 
 	assert.Equal(t, 404, w.Code)
-	assert.Equal(t, "{\"message\":\"Page or Method not found\"}", w.Body.String())
 
 }

+ 9 - 0
pkg/apiserver/controllers/controller.go

@@ -2,6 +2,7 @@ package controllers
 
 import (
 	"context"
+	"net/http"
 
 	v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1"
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
@@ -43,6 +44,14 @@ func (c *Controller) NewV1() error {
 	}
 
 	c.Router.Use(v1.PrometheusMiddleware())
+	c.Router.HandleMethodNotAllowed = true
+	c.Router.NoRoute(func(ctx *gin.Context) {
+		ctx.AbortWithStatus(http.StatusNotFound)
+	})
+	c.Router.NoMethod(func(ctx *gin.Context) {
+		ctx.AbortWithStatus(http.StatusMethodNotAllowed)
+	})
+
 	groupV1 := c.Router.Group("/v1")
 	groupV1.POST("/watchers", handlerV1.CreateMachine)
 	groupV1.POST("/watchers/login", handlerV1.Middlewares.JWT.Middleware.LoginHandler)

+ 7 - 3
pkg/apiserver/controllers/v1/alerts.go

@@ -146,7 +146,7 @@ func (c *Controller) CreateAlert(gctx *gin.Context) {
 		log.Warningf("Cannot send alert to Central API channel")
 	}
 
-	gctx.JSON(http.StatusOK, alerts)
+	gctx.JSON(http.StatusCreated, alerts)
 	return
 }
 
@@ -202,11 +202,15 @@ func (c *Controller) DeleteAlerts(gctx *gin.Context) {
 		return
 	}
 	var err error
-	deleted, err := c.DBClient.DeleteAlertWithFilter(gctx.Request.URL.Query())
+	nbDeleted, err := c.DBClient.DeleteAlertWithFilter(gctx.Request.URL.Query())
 	if err != nil {
 		c.HandleDBErrors(gctx, err)
 	}
 
-	gctx.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d deleted alerts", len(deleted))})
+	deleteAlertsResp := models.DeleteAlertsResponse{
+		NbDeleted: strconv.Itoa(nbDeleted),
+	}
+
+	gctx.JSON(http.StatusOK, deleteAlertsResp)
 	return
 }

+ 3 - 0
pkg/apiserver/controllers/v1/errors.go

@@ -10,6 +10,9 @@ import (
 
 func (c *Controller) HandleDBErrors(gctx *gin.Context, err error) {
 	switch errors.Cause(err) {
+	case database.ItemNotFound:
+		gctx.JSON(http.StatusNotFound, gin.H{"message": err.Error()})
+		return
 	case database.UserExists:
 		gctx.JSON(http.StatusForbidden, gin.H{"message": err.Error()})
 		return

+ 1 - 1
pkg/apiserver/controllers/v1/machines.go

@@ -28,6 +28,6 @@ func (c *Controller) CreateMachine(gctx *gin.Context) {
 		return
 	}
 
-	gctx.Status(http.StatusOK)
+	gctx.Status(http.StatusCreated)
 	return
 }

+ 1 - 1
pkg/apiserver/machines_test.go

@@ -47,7 +47,7 @@ func TestCreateMachine(t *testing.T) {
 	req.Header.Add("User-Agent", UserAgent)
 	router.ServeHTTP(w, req)
 
-	assert.Equal(t, 200, w.Code)
+	assert.Equal(t, 201, w.Code)
 	assert.Equal(t, "", w.Body.String())
 
 }

+ 11 - 6
pkg/database/alerts.go

@@ -491,7 +491,7 @@ func (c *Client) DeleteAlertGraph(alertItem *ent.Alert) error {
 	return nil
 }
 
-func (c *Client) DeleteAlertWithFilter(filter map[string][]string) ([]*ent.Alert, error) {
+func (c *Client) DeleteAlertWithFilter(filter map[string][]string) (int, error) {
 	var err error
 
 	// Get all the alerts that match the filter
@@ -501,10 +501,10 @@ func (c *Client) DeleteAlertWithFilter(filter map[string][]string) ([]*ent.Alert
 		err = c.DeleteAlertGraph(alertItem)
 		if err != nil {
 			log.Warningf("DeleteAlertWithFilter : %s", err)
-			return []*ent.Alert{}, errors.Wrapf(DeleteFail, "event with alert ID '%d'", alertItem.ID)
+			return 0, errors.Wrapf(DeleteFail, "event with alert ID '%d'", alertItem.ID)
 		}
 	}
-	return alertsToDelete, nil
+	return len(alertsToDelete), nil
 }
 
 func (c *Client) FlushAlerts(MaxAge string, MaxItems int) error {
@@ -521,12 +521,12 @@ func (c *Client) FlushAlerts(MaxAge string, MaxItems int) error {
 		filter := map[string][]string{
 			"created_before": {MaxAge},
 		}
-		deleted, err := c.DeleteAlertWithFilter(filter)
+		nbDeleted, err := c.DeleteAlertWithFilter(filter)
 		if err != nil {
 			log.Warningf("FlushAlerts (max age) : %s", err)
 			return errors.Wrapf(err, "unable to flush alerts with filter until: %s", MaxAge)
 		}
-		deletedByAge = len(deleted)
+		deletedByAge = nbDeleted
 	}
 	if MaxItems > 0 {
 		if totalAlerts > MaxItems {
@@ -563,8 +563,13 @@ func (c *Client) FlushAlerts(MaxAge string, MaxItems int) error {
 func (c *Client) GetAlertByID(alertID int) (*ent.Alert, error) {
 	alert, err := c.Ent.Alert.Query().Where(alert.IDEQ(alertID)).WithDecisions().WithEvents().WithMetas().WithOwner().First(c.CTX)
 	if err != nil {
+		/*record not found, 404*/
+		if ent.IsNotFound(err) {
+			log.Warningf("GetAlertByID (not found): %s", err)
+			return &ent.Alert{}, ItemNotFound
+		}
 		log.Warningf("GetAlertByID : %s", err)
-		return &ent.Alert{}, errors.Wrapf(QueryFail, "alert id '%d'", alertID)
+		return &ent.Alert{}, QueryFail
 	}
 	return alert, nil
 }

+ 4 - 0
pkg/database/decisions.go

@@ -278,5 +278,9 @@ func (c *Client) SoftDeleteDecisionByID(decisionID int) error {
 		log.Warningf("SoftDeleteDecisionByID : %v (nb soft deleted: %d)", err, nbUpdated)
 		return errors.Wrapf(DeleteFail, "decision with id '%d' doesn't exist", decisionID)
 	}
+
+	if nbUpdated == 0 {
+		return ItemNotFound
+	}
 	return nil
 }

+ 1 - 0
pkg/database/errors.go

@@ -10,6 +10,7 @@ var (
 	QueryFail         = errors.New("unable to query")
 	UpdateFail        = errors.New("unable to update")
 	DeleteFail        = errors.New("unable to delete")
+	ItemNotFound      = errors.New("object not found")
 	ParseTimeFail     = errors.New("unable to parse time")
 	ParseDurationFail = errors.New("unable to parse duration")
 	MarshalFail       = errors.New("unable to marshal")

+ 4 - 4
pkg/models/localapi_swagger.yaml

@@ -267,8 +267,8 @@ paths:
           schema:
             $ref: '#/definitions/WatcherRegistrationRequest'
       responses:
-        '200':
-          description: Watcher registered
+        '201':
+          description: Watcher Created
           headers: {}
         '400':
           description: "400 response"
@@ -322,8 +322,8 @@ paths:
           schema:
             $ref: '#/definitions/AddAlertsRequest'
       responses:
-        '200':
-          description: successful operation
+        '201':
+          description: Alert(s) created
           schema:
             $ref: '#/definitions/AddAlertsResponse'
           headers: {}