From 71ac0d2fce1b6e62b8f2ab65c6223e5fe7cf28b7 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Mon, 30 Nov 2020 16:15:07 +0100 Subject: [PATCH] Apiclient tests (#484) Co-authored-by: AlteredCoder Co-authored-by: erenJag --- docs/{v1.X/docs => }/contributing.md | 0 docs/v0.3.X/docs/contributing.md | 30 -- docs/v0.3.X/mkdocs.yml | 1 - docs/v1.X/docs/about.md | 3 - docs/v1.X/mkdocs.yml | 5 - mkdocs.yml | 3 +- pkg/apiclient/alerts_service.go | 9 +- pkg/apiclient/alerts_service_test.go | 496 +++++++++++++++++++++++ pkg/apiclient/auth.go | 4 +- pkg/apiclient/auth_service_test.go | 181 +++++++++ pkg/apiclient/auth_test.go | 83 ++++ pkg/apiclient/client.go | 23 +- pkg/apiclient/client_http.go | 8 +- pkg/apiclient/client_http_test.go | 77 ++++ pkg/apiclient/client_test.go | 200 +++++++++ pkg/apiclient/decisions_service.go | 2 +- pkg/apiclient/decisions_service_test.go | 273 +++++++++++++ pkg/apiserver/alerts_test.go | 4 +- pkg/apiserver/apiserver_test.go | 1 - pkg/apiserver/controllers/controller.go | 9 + pkg/apiserver/controllers/v1/alerts.go | 10 +- pkg/apiserver/controllers/v1/errors.go | 3 + pkg/apiserver/controllers/v1/machines.go | 2 +- pkg/apiserver/machines_test.go | 2 +- pkg/database/alerts.go | 17 +- pkg/database/decisions.go | 4 + pkg/database/errors.go | 1 + pkg/models/localapi_swagger.yaml | 8 +- 28 files changed, 1385 insertions(+), 74 deletions(-) rename docs/{v1.X/docs => }/contributing.md (100%) delete mode 100644 docs/v0.3.X/docs/contributing.md delete mode 100644 docs/v1.X/docs/about.md create mode 100644 pkg/apiclient/alerts_service_test.go create mode 100644 pkg/apiclient/auth_service_test.go create mode 100644 pkg/apiclient/auth_test.go create mode 100644 pkg/apiclient/client_http_test.go create mode 100644 pkg/apiclient/client_test.go create mode 100644 pkg/apiclient/decisions_service_test.go diff --git a/docs/v1.X/docs/contributing.md b/docs/contributing.md similarity index 100% rename from docs/v1.X/docs/contributing.md rename to docs/contributing.md diff --git a/docs/v0.3.X/docs/contributing.md b/docs/v0.3.X/docs/contributing.md deleted file mode 100644 index 5906a5776..000000000 --- a/docs/v0.3.X/docs/contributing.md +++ /dev/null @@ -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 ! - diff --git a/docs/v0.3.X/mkdocs.yml b/docs/v0.3.X/mkdocs.yml index 19f0f835a..3af333e7f 100644 --- a/docs/v0.3.X/mkdocs.yml +++ b/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 diff --git a/docs/v1.X/docs/about.md b/docs/v1.X/docs/about.md deleted file mode 100644 index a0c46d408..000000000 --- a/docs/v1.X/docs/about.md +++ /dev/null @@ -1,3 +0,0 @@ -# Crowdsec - -{{macros_info() }} diff --git a/docs/v1.X/mkdocs.yml b/docs/v1.X/mkdocs.yml index d354d8dd6..61ef2f034 100644 --- a/docs/v1.X/mkdocs.yml +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 02995e2fe..2b2a3b987 100644 --- a/mkdocs.yml +++ b/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)" diff --git a/pkg/apiclient/alerts_service.go b/pkg/apiclient/alerts_service.go index 07a9af74a..41fca4973 100644 --- a/pkg/apiclient/alerts_service.go +++ b/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 } diff --git a/pkg/apiclient/alerts_service_test.go b/pkg/apiclient/alerts_service_test.go new file mode 100644 index 000000000..93cf17c01 --- /dev/null +++ b/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) + } +} diff --git a/pkg/apiclient/auth.go b/pkg/apiclient/auth.go index 7c4bcd687..cf6ec2a08 100644 --- a/pkg/apiclient/auth.go +++ b/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{ diff --git a/pkg/apiclient/auth_service_test.go b/pkg/apiclient/auth_service_test.go new file mode 100644 index 000000000..12019e962 --- /dev/null +++ b/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) +} diff --git a/pkg/apiclient/auth_test.go b/pkg/apiclient/auth_test.go new file mode 100644 index 000000000..671a2c7e8 --- /dev/null +++ b/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") + +} diff --git a/pkg/apiclient/client.go b/pkg/apiclient/client.go index 8b3f03ba3..c07b53c26 100644 --- a/pkg/apiclient/client.go +++ b/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 } diff --git a/pkg/apiclient/client_http.go b/pkg/apiclient/client_http.go index dffadb5ba..2b9049d90 100644 --- a/pkg/apiclient/client_http.go +++ b/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 } diff --git a/pkg/apiclient/client_http_test.go b/pkg/apiclient/client_http_test.go new file mode 100644 index 000000000..27f949d0d --- /dev/null +++ b/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`) +} diff --git a/pkg/apiclient/client_test.go b/pkg/apiclient/client_test.go new file mode 100644 index 000000000..92e1b0309 --- /dev/null +++ b/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`) +} diff --git a/pkg/apiclient/decisions_service.go b/pkg/apiclient/decisions_service.go index ed5f1da71..10d15192e 100644 --- a/pkg/apiclient/decisions_service.go +++ b/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 { diff --git a/pkg/apiclient/decisions_service_test.go b/pkg/apiclient/decisions_service_test.go new file mode 100644 index 000000000..07cb0180e --- /dev/null +++ b/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() +// } diff --git a/pkg/apiserver/alerts_test.go b/pkg/apiserver/alerts_test.go index becf128cd..92eeacc68 100644 --- a/pkg/apiserver/alerts_test.go +++ b/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()) } diff --git a/pkg/apiserver/apiserver_test.go b/pkg/apiserver/apiserver_test.go index f0b7da3f7..b35e7f907 100644 --- a/pkg/apiserver/apiserver_test.go +++ b/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()) } diff --git a/pkg/apiserver/controllers/controller.go b/pkg/apiserver/controllers/controller.go index bf7dd764e..81b7db0cf 100644 --- a/pkg/apiserver/controllers/controller.go +++ b/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) diff --git a/pkg/apiserver/controllers/v1/alerts.go b/pkg/apiserver/controllers/v1/alerts.go index 9d5289447..57e2f485a 100644 --- a/pkg/apiserver/controllers/v1/alerts.go +++ b/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 } diff --git a/pkg/apiserver/controllers/v1/errors.go b/pkg/apiserver/controllers/v1/errors.go index 4aaf1aaf7..5edf0d6bf 100644 --- a/pkg/apiserver/controllers/v1/errors.go +++ b/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 diff --git a/pkg/apiserver/controllers/v1/machines.go b/pkg/apiserver/controllers/v1/machines.go index 645eb636b..38681d540 100644 --- a/pkg/apiserver/controllers/v1/machines.go +++ b/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 } diff --git a/pkg/apiserver/machines_test.go b/pkg/apiserver/machines_test.go index 1ae05f0c0..18c12b3e0 100644 --- a/pkg/apiserver/machines_test.go +++ b/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()) } diff --git a/pkg/database/alerts.go b/pkg/database/alerts.go index 91e354961..13f8d8a20 100644 --- a/pkg/database/alerts.go +++ b/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 } diff --git a/pkg/database/decisions.go b/pkg/database/decisions.go index 2f2ec9e8f..338677060 100644 --- a/pkg/database/decisions.go +++ b/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 } diff --git a/pkg/database/errors.go b/pkg/database/errors.go index 042d84408..8e96f52d7 100644 --- a/pkg/database/errors.go +++ b/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") diff --git a/pkg/models/localapi_swagger.yaml b/pkg/models/localapi_swagger.yaml index 5dbff0f57..ec315cf3a 100644 --- a/pkg/models/localapi_swagger.yaml +++ b/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: {}