package opsgenie import ( "bytes" "crypto/tls" "fmt" "io" "log" "net" "net/http" "net/http/httptest" "net/url" "testing" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/nicholas-fedor/shoutrrr/pkg/types" ) const ( mockAPIKey = "eb243592-faa2-4ba2-a551q-1afdf565c889" mockHost = "api.opsgenie.com" ) func TestOpsGenie(t *testing.T) { gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, "Shoutrrr OpsGenie Suite") } var _ = ginkgo.Describe("the OpsGenie service", func() { var ( // a simulated http server to mock out OpsGenie itself mockServer *httptest.Server // the host of our mock server mockHost string // function to check if the http request received by the mock server is as expected checkRequest func(body string, header http.Header) // the shoutrrr OpsGenie service service *Service // just a mock logger mockLogger *log.Logger ) ginkgo.BeforeEach(func() { // Initialize a mock http server httpHandler := func(_ http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) gomega.Expect(err).ToNot(gomega.HaveOccurred()) defer r.Body.Close() checkRequest(string(body), r.Header) } mockServer = httptest.NewTLSServer(http.HandlerFunc(httpHandler)) // Our mock server doesn't have a valid cert http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } // Determine the host of our mock http server mockServerURL, err := url.Parse(mockServer.URL) gomega.Expect(err).ToNot(gomega.HaveOccurred()) mockHost = mockServerURL.Host // Initialize a mock logger var buf bytes.Buffer mockLogger = log.New(&buf, "", 0) }) ginkgo.AfterEach(func() { mockServer.Close() }) ginkgo.Context("without query parameters", func() { ginkgo.BeforeEach(func() { // Initialize service serviceURL, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) service = &Service{} err = service.Initialize(serviceURL, mockLogger) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }) ginkgo.When("sending a simple alert", func() { ginkgo.It("should send a request to our mock OpsGenie server", func() { checkRequest = func(body string, header http.Header) { gomega.Expect(header["Authorization"][0]). To(gomega.Equal("GenieKey " + mockAPIKey)) gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) gomega.Expect(body).To(gomega.Equal(`{"message":"hello world"}`)) } err := service.Send("hello world", &types.Params{}) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }) }) ginkgo.When("sending an alert with runtime parameters", func() { ginkgo.It( "should send a request to our mock OpsGenie server with all fields populated from runtime parameters", func() { checkRequest = func(body string, header http.Header) { gomega.Expect(header["Authorization"][0]). To(gomega.Equal("GenieKey " + mockAPIKey)) gomega.Expect(header["Content-Type"][0]). To(gomega.Equal("application/json")) gomega.Expect(body).To(gomega.Equal(`{"` + `message":"An example alert message",` + `"alias":"Life is too short for no alias",` + `"description":"Every alert needs a description",` + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + `"visibleTo":[{"type":"team","name":"rocket"}],` + `"actions":["action1","action2"],` + `"tags":["tag1","tag2"],` + `"details":{"key1":"value1","key2":"value2"},` + `"entity":"An example entity",` + `"source":"The source",` + `"priority":"P1",` + `"user":"Dracula",` + `"note":"Here is a note"` + `}`)) } err := service.Send("An example alert message", &types.Params{ "alias": "Life is too short for no alias", "description": "Every alert needs a description", "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", "visibleTo": "team:rocket", "actions": "action1,action2", "tags": "tag1,tag2", "details": "key1:value1,key2:value2", "entity": "An example entity", "source": "The source", "priority": "P1", "user": "Dracula", "note": "Here is a note", }) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }, ) }) }) ginkgo.Context("with query parameters", func() { ginkgo.BeforeEach(func() { // Initialize service serviceURL, err := url.Parse( fmt.Sprintf( `opsgenie://%s/%s?alias=query-alias&description=query-description&responders=team:query_team&visibleTo=user:query_user&actions=queryAction1,queryAction2&tags=queryTag1,queryTag2&details=queryKey1:queryValue1,queryKey2:queryValue2&entity=query-entity&source=query-source&priority=P2&user=query-user¬e=query-note`, mockHost, mockAPIKey, ), ) gomega.Expect(err).ToNot(gomega.HaveOccurred()) service = &Service{} err = service.Initialize(serviceURL, mockLogger) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }) ginkgo.When("sending a simple alert", func() { ginkgo.It( "should send a request to our mock OpsGenie server with all fields populated from query parameters", func() { checkRequest = func(body string, header http.Header) { gomega.Expect(header["Authorization"][0]). To(gomega.Equal("GenieKey " + mockAPIKey)) gomega.Expect(header["Content-Type"][0]). To(gomega.Equal("application/json")) gomega.Expect(body).To(gomega.Equal(`{` + `"message":"An example alert message",` + `"alias":"query-alias",` + `"description":"query-description",` + `"responders":[{"type":"team","name":"query_team"}],` + `"visibleTo":[{"type":"user","username":"query_user"}],` + `"actions":["queryAction1","queryAction2"],` + `"tags":["queryTag1","queryTag2"],` + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + `"entity":"query-entity",` + `"source":"query-source",` + `"priority":"P2",` + `"user":"query-user",` + `"note":"query-note"` + `}`)) } err := service.Send("An example alert message", &types.Params{}) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }, ) }) ginkgo.When("sending two alerts", func() { ginkgo.It("should not mix-up the runtime parameters and the query parameters", func() { // Internally the opsgenie service copies runtime parameters into the config struct // before generating the alert payload. This test ensures that none of the parameters // from alert 1 remain in the config struct when sending alert 2 // In short: This tests if we clone the config struct checkRequest = func(body string, header http.Header) { gomega.Expect(header["Authorization"][0]). To(gomega.Equal("GenieKey " + mockAPIKey)) gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) gomega.Expect(body).To(gomega.Equal(`{"` + `message":"1",` + `"alias":"1",` + `"description":"1",` + `"responders":[{"type":"team","name":"1"}],` + `"visibleTo":[{"type":"team","name":"1"}],` + `"actions":["action1","action2"],` + `"tags":["tag1","tag2"],` + `"details":{"key1":"value1","key2":"value2"},` + `"entity":"1",` + `"source":"1",` + `"priority":"P1",` + `"user":"1",` + `"note":"1"` + `}`)) } err := service.Send("1", &types.Params{ "alias": "1", "description": "1", "responders": "team:1", "visibleTo": "team:1", "actions": "action1,action2", "tags": "tag1,tag2", "details": "key1:value1,key2:value2", "entity": "1", "source": "1", "priority": "P1", "user": "1", "note": "1", }) gomega.Expect(err).ToNot(gomega.HaveOccurred()) checkRequest = func(body string, header http.Header) { gomega.Expect(header["Authorization"][0]). To(gomega.Equal("GenieKey " + mockAPIKey)) gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) gomega.Expect(body).To(gomega.Equal(`{` + `"message":"2",` + `"alias":"query-alias",` + `"description":"query-description",` + `"responders":[{"type":"team","name":"query_team"}],` + `"visibleTo":[{"type":"user","username":"query_user"}],` + `"actions":["queryAction1","queryAction2"],` + `"tags":["queryTag1","queryTag2"],` + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + `"entity":"query-entity",` + `"source":"query-source",` + `"priority":"P2",` + `"user":"query-user",` + `"note":"query-note"` + `}`)) } err = service.Send("2", nil) gomega.Expect(err).ToNot(gomega.HaveOccurred()) }) }) }) ginkgo.It("should return the correct service ID", func() { service := &Service{} gomega.Expect(service.GetID()).To(gomega.Equal("opsgenie")) }) }) var _ = ginkgo.Describe("the OpsGenie Config struct", func() { ginkgo.When("generating a config from a simple URL", func() { ginkgo.It("should populate the config with host and apikey", func() { url, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) gomega.Expect(err).ToNot(gomega.HaveOccurred()) config := Config{} err = config.SetURL(url) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(config.APIKey).To(gomega.Equal(mockAPIKey)) gomega.Expect(config.Host).To(gomega.Equal(mockHost)) gomega.Expect(config.Port).To(gomega.Equal(uint16(443))) }) }) ginkgo.When("generating a config from a url with port", func() { ginkgo.It("should populate the port field", func() { url, err := url.Parse( fmt.Sprintf("opsgenie://%s/%s", net.JoinHostPort(mockHost, "12345"), mockAPIKey), ) gomega.Expect(err).ToNot(gomega.HaveOccurred()) config := Config{} err = config.SetURL(url) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(config.Port).To(gomega.Equal(uint16(12345))) }) }) ginkgo.When("generating a config from a url with query parameters", func() { ginkgo.It("should populate the config fields with the query parameter values", func() { queryParams := `alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=tag1,tag2&details=key:value,key2:value2&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note&responders=user:Test,team:NOC&visibleTo=user:A+User` url, err := url.Parse( fmt.Sprintf( "opsgenie://%s/%s?%s", net.JoinHostPort(mockHost, "12345"), mockAPIKey, queryParams, ), ) gomega.Expect(err).ToNot(gomega.HaveOccurred()) config := Config{} err = config.SetURL(url) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias")) gomega.Expect(config.Description).To(gomega.Equal("Every alert needs a description")) gomega.Expect(config.Responders).To(gomega.Equal([]Entity{ {Type: "user", Username: "Test"}, {Type: "team", Name: "NOC"}, })) gomega.Expect(config.VisibleTo).To(gomega.Equal([]Entity{ {Type: "user", Username: "A User"}, })) gomega.Expect(config.Actions).To(gomega.Equal([]string{"An action"})) gomega.Expect(config.Tags).To(gomega.Equal([]string{"tag1", "tag2"})) gomega.Expect(config.Details). To(gomega.Equal(map[string]string{"key": "value", "key2": "value2"})) gomega.Expect(config.Entity).To(gomega.Equal("An example entity")) gomega.Expect(config.Source).To(gomega.Equal("The source")) gomega.Expect(config.Priority).To(gomega.Equal("P1")) gomega.Expect(config.User).To(gomega.Equal("Dracula")) gomega.Expect(config.Note).To(gomega.Equal("Here is a note")) }) }) ginkgo.When("generating a config from a url with differently escaped spaces", func() { ginkgo.It("should parse the escaped spaces correctly", func() { // Use: '%20', '+' and a normal space queryParams := `alias=Life is+too%20short+for+no+alias` url, err := url.Parse( fmt.Sprintf( "opsgenie://%s/%s?%s", net.JoinHostPort(mockHost, "12345"), mockAPIKey, queryParams, ), ) gomega.Expect(err).ToNot(gomega.HaveOccurred()) config := Config{} err = config.SetURL(url) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias")) }) }) ginkgo.When("generating a url from a simple config", func() { ginkgo.It("should generate a url", func() { config := Config{ Host: "api.opsgenie.com", APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", } url := config.GetURL() gomega.Expect(url.String()). To(gomega.Equal("opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889")) }) }) ginkgo.When("generating a url from a config with a port", func() { ginkgo.It("should generate a url with port", func() { config := Config{ Host: "api.opsgenie.com", APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", Port: 12345, } url := config.GetURL() gomega.Expect(url.String()). To(gomega.Equal("opsgenie://api.opsgenie.com:12345/eb243592-faa2-4ba2-a551q-1afdf565c889")) }) }) ginkgo.When("generating a url from a config with all optional config fields", func() { ginkgo.It("should generate a url with query parameters", func() { config := Config{ Host: "api.opsgenie.com", APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", Alias: "Life is too short for no alias", Description: "Every alert needs a description", Responders: []Entity{ {Type: "user", Username: "Test"}, {Type: "team", Name: "NOC"}, {Type: "team", ID: "4513b7ea-3b91-438f-b7e4-e3e54af9147c"}, }, VisibleTo: []Entity{ {Type: "user", Username: "A User"}, }, Actions: []string{"action1", "action2"}, Tags: []string{"tag1", "tag2"}, Details: map[string]string{"key": "value"}, Entity: "An example entity", Source: "The source", Priority: "P1", User: "Dracula", Note: "Here is a note", } url := config.GetURL() gomega.Expect(url.String()). To(gomega.Equal(`opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?actions=action1%2Caction2&alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&details=key%3Avalue&entity=An+example+entity¬e=Here+is+a+note&priority=P1&responders=user%3ATest%2Cteam%3ANOC%2Cteam%3A4513b7ea-3b91-438f-b7e4-e3e54af9147c&source=The+source&tags=tag1%2Ctag2&user=Dracula&visibleto=user%3AA+User`)) }) }) })