diff --git a/integrations/access/opsgenie/opsgenie_test.go b/integrations/access/opsgenie/opsgenie_test.go deleted file mode 100644 index 5a86d8cb177ef..0000000000000 --- a/integrations/access/opsgenie/opsgenie_test.go +++ /dev/null @@ -1,574 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package opsgenie - -import ( - "os/user" - "runtime" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/gravitational/teleport/api/client/proto" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/types/wrappers" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/teleport/integrations/lib/testing/integration" -) - -const ( - NotifyServiceName = "Teleport Notifications" - NotifyServiceAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifyServicesLabel - ResponderName1 = "Responder 1" - ResponderName2 = "Responder 2" - ResponderName3 = "Responder 3" -) - -type OpsgenieSuite struct { - integration.Suite - appConfig Config - currentRequestor string - userNames struct { - ruler string - reviewer1 string - reviewer2 string - requestor string - approver string - racer1 string - racer2 string - plugin string - } - raceNumber int - fakeOpsgenie *FakeOpsgenie - - ogNotifyResponder Responder - ogResponder1 Responder - ogResponder2 Responder - ogResponder3 Responder - - clients map[string]*integration.Client - teleportFeatures *proto.Features - teleportConfig lib.TeleportConfig -} - -func TestOpsgenieSuite(t *testing.T) { suite.Run(t, &OpsgenieSuite{}) } - -func (s *OpsgenieSuite) SetupSuite() { - var err error - t := s.T() - - logger.Init() - err = logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - s.raceNumber = 2 * runtime.GOMAXPROCS(0) - me, err := user.Current() - require.NoError(t, err) - - // We set such a big timeout because integration.NewFromEnv could start - // downloading a Teleport *-bin.tar.gz file which can take a long time. - ctx := s.SetContextTimeout(2 * time.Minute) - - teleport, err := integration.NewFromEnv(ctx) - require.NoError(t, err) - require.NotNil(t, teleport) - t.Cleanup(teleport.Close) - - auth, err := teleport.NewAuthService() - require.NoError(t, err) - require.NotNil(t, auth) - s.StartApp(auth) - - s.clients = make(map[string]*integration.Client) - - // Set up the user who has an access to all kinds of resources. - - s.userNames.ruler = me.Username + "-ruler@example.com" - client, err := teleport.MakeAdmin(ctx, auth, s.userNames.ruler) - require.NoError(t, err) - s.clients[s.userNames.ruler] = client - - // Get the server features. - - pong, err := client.Ping(ctx) - require.NoError(t, err) - teleportFeatures := pong.GetServerFeatures() - - var bootstrap integration.Bootstrap - - // Set up user who can request the access to role "editor". - - conditions := types.RoleConditions{ - Request: &types.AccessRequestConditions{ - Roles: []string{"editor"}, - Annotations: wrappers.Traits{ - NotifyServiceAnnotation: []string{NotifyServiceName}, - }, - }, - } - if teleportFeatures.AdvancedAccessWorkflows { - conditions.Request.Thresholds = []types.AccessReviewThreshold{{Approve: 2, Deny: 2}} - } - // This is the role for testing notification alert creation. - role, err := bootstrap.AddRole("foo", types.RoleSpecV6{Allow: conditions}) - require.NoError(t, err) - - user, err := bootstrap.AddUserWithRoles(me.Username+"@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.requestor = user.GetName() - - if teleportFeatures.AdvancedAccessWorkflows { - // Set up TWO users who can review access requests to role "editor". - - role, err = bootstrap.AddRole("foo-reviewer", types.RoleSpecV6{ - Allow: types.RoleConditions{ - ReviewRequests: &types.AccessReviewConditions{Roles: []string{"editor"}}, - }, - }) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles(me.Username+"-reviewer1@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.reviewer1 = user.GetName() - - user, err = bootstrap.AddUserWithRoles(me.Username+"-reviewer2@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.reviewer2 = user.GetName() - - // This is the role that needs exactly one approval review for an access request to be approved. - // It's handy to test auto-approval scenarios so we also put "opsgenie_services" annotation. - role, err = bootstrap.AddRole("bar", types.RoleSpecV6{ - Allow: types.RoleConditions{ - Request: &types.AccessRequestConditions{ - Roles: []string{"editor"}, - Annotations: wrappers.Traits{ - NotifyServiceAnnotation: []string{ResponderName1, ResponderName2}, - }, - }, - }, - }) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles(me.Username+"-approver@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.approver = user.GetName() - - // This is the role with a maximum possible setup: both "opsgenie_notify_service" and - // "opsgenie_services" annotations and threshold. - role, err = bootstrap.AddRole("foo-bar", types.RoleSpecV6{ - Allow: types.RoleConditions{ - Request: &types.AccessRequestConditions{ - Roles: []string{"editor"}, - Annotations: wrappers.Traits{ - NotifyServiceAnnotation: []string{NotifyServiceName}, - // ServicesDefaultAnnotation: []string{ServiceName1, ServiceName2}, // TODO: FIX THIS - }, - Thresholds: []types.AccessReviewThreshold{types.AccessReviewThreshold{Approve: 2, Deny: 2}}, - }, - }, - }) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles(me.Username+"-racer1@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.racer1 = user.GetName() - - user, err = bootstrap.AddUserWithRoles(me.Username+"-racer2@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.racer2 = user.GetName() - } - - conditions = types.RoleConditions{ - Rules: []types.Rule{ - types.NewRule("access_request", []string{"list", "read"}), - types.NewRule("access_plugin_data", []string{"update"}), - }, - } - if teleportFeatures.AdvancedAccessWorkflows { - conditions.ReviewRequests = &types.AccessReviewConditions{Roles: []string{"editor"}} - } - - // Set up plugin user. - - role, err = bootstrap.AddRole("access-opsgenie", types.RoleSpecV6{Allow: conditions}) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles("access-opsgenie", role.GetName()) - require.NoError(t, err) - s.userNames.plugin = user.GetName() - - // Bake all the resources. - - err = teleport.Bootstrap(ctx, auth, bootstrap.Resources()) - require.NoError(t, err) - - // Initialize the clients. - - client, err = teleport.NewClient(ctx, auth, s.userNames.requestor) - require.NoError(t, err) - s.clients[s.userNames.requestor] = client - - if teleportFeatures.AdvancedAccessWorkflows { - client, err = teleport.NewClient(ctx, auth, s.userNames.approver) - require.NoError(t, err) - s.clients[s.userNames.approver] = client - - client, err = teleport.NewClient(ctx, auth, s.userNames.reviewer1) - require.NoError(t, err) - s.clients[s.userNames.reviewer1] = client - - client, err = teleport.NewClient(ctx, auth, s.userNames.reviewer2) - require.NoError(t, err) - s.clients[s.userNames.reviewer2] = client - - client, err = teleport.NewClient(ctx, auth, s.userNames.racer1) - require.NoError(t, err) - s.clients[s.userNames.racer1] = client - - client, err = teleport.NewClient(ctx, auth, s.userNames.racer2) - require.NoError(t, err) - s.clients[s.userNames.racer2] = client - } - - identityPath, err := teleport.Sign(ctx, auth, s.userNames.plugin) - require.NoError(t, err) - - s.teleportConfig.Addr = auth.AuthAddr().String() - s.teleportConfig.Identity = identityPath - s.teleportFeatures = teleportFeatures -} - -func (s *OpsgenieSuite) SetupTest() { - t := s.T() - - err := logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - - fakeOpsgenie := NewFakeOpsgenie(s.raceNumber) - t.Cleanup(fakeOpsgenie.Close) - s.fakeOpsgenie = fakeOpsgenie - - s.ogNotifyResponder = s.fakeOpsgenie.StoreResponder(Responder{ - Name: NotifyServiceName, - }) - s.ogResponder1 = s.fakeOpsgenie.StoreResponder(Responder{ - Name: ResponderName1, - }) - s.ogResponder2 = s.fakeOpsgenie.StoreResponder(Responder{ - Name: ResponderName2, - }) - s.ogResponder3 = s.fakeOpsgenie.StoreResponder(Responder{ - Name: ResponderName3, - }) - - var conf Config - conf.Teleport = s.teleportConfig - conf.ClientConfig.APIEndpoint = s.fakeOpsgenie.URL() - - s.appConfig = conf - s.currentRequestor = s.userNames.requestor - s.SetContextTimeout(5 * time.Second) -} - -func (s *OpsgenieSuite) startApp() { - t := s.T() - t.Helper() - - app, err := NewOpsgenieApp(s.Context(), &s.appConfig) - require.NoError(t, err) - - s.StartApp(app) -} - -func (s *OpsgenieSuite) ruler() *integration.Client { - return s.clients[s.userNames.ruler] -} - -func (s *OpsgenieSuite) requestor() *integration.Client { - return s.clients[s.currentRequestor] -} - -func (s *OpsgenieSuite) reviewer1() *integration.Client { - return s.clients[s.userNames.reviewer1] -} - -func (s *OpsgenieSuite) reviewer2() *integration.Client { - return s.clients[s.userNames.reviewer2] -} - -func (s *OpsgenieSuite) newAccessRequest() types.AccessRequest { - t := s.T() - t.Helper() - - req, err := types.NewAccessRequest(uuid.New().String(), s.currentRequestor, "editor") - req.SetSystemAnnotations(map[string][]string{ - NotifyServiceAnnotation: {NotifyServiceName}, - }) - require.NoError(s.T(), err) - return req -} - -func (s *OpsgenieSuite) createAccessRequest() types.AccessRequest { - t := s.T() - t.Helper() - - req := s.newAccessRequest() - out, err := s.requestor().CreateAccessRequestV2(s.Context(), req) - require.NoError(t, err) - return out -} - -func (s *OpsgenieSuite) checkPluginData(reqID string, cond func(PluginData) bool) PluginData { - t := s.T() - t.Helper() - - for { - rawData, err := s.ruler().PollAccessRequestPluginData(s.Context(), "opsgenie", reqID) - require.NoError(t, err) - if data := DecodePluginData(rawData); cond(data) { - return data - } - } -} - -func (s *OpsgenieSuite) TestAlertCreation() { - t := s.T() - - s.startApp() - - req := s.createAccessRequest() - pluginData := s.checkPluginData(req.GetName(), func(data PluginData) bool { - return data.AlertID != "" - }) - - alert, err := s.fakeOpsgenie.CheckNewAlert(s.Context()) - require.NoError(t, err, "no new alerts stored") - - assert.Equal(t, alert.ID, pluginData.AlertID) -} - -func (s *OpsgenieSuite) TestApproval() { - t := s.T() - - s.startApp() - - req := s.createAccessRequest() - - alert, err := s.fakeOpsgenie.CheckNewAlert(s.Context()) - require.NoError(t, err, "no new alerts stored") - - err = s.ruler().ApproveAccessRequest(s.Context(), req.GetName(), "okay") - require.NoError(t, err) - - note, err := s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, "Access request has been approved") - assert.Contains(t, note.Note, "Reason: okay") - - alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(s.Context()) - require.NoError(t, err) - assert.Equal(t, "resolved", alertUpdate.Status) -} - -func (s *OpsgenieSuite) TestDenial() { - t := s.T() - - s.startApp() - - req := s.createAccessRequest() - - alert, err := s.fakeOpsgenie.CheckNewAlert(s.Context()) - require.NoError(t, err, "no new alerts stored") - - err = s.ruler().DenyAccessRequest(s.Context(), req.GetName(), "not okay") - require.NoError(t, err) - - note, err := s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, "Access request has been denied") - assert.Contains(t, note.Note, "Reason: not okay") - - alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(s.Context()) - require.NoError(t, err) - assert.Equal(t, "resolved", alertUpdate.Status) -} - -func (s *OpsgenieSuite) TestReviewNotes() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - s.startApp() - - req := s.createAccessRequest() - - err := s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }) - require.NoError(t, err) - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "not okay", - }) - require.NoError(t, err) - - pluginData := s.checkPluginData(req.GetName(), func(data PluginData) bool { - return data.AlertID != "" && data.ReviewsCount == 2 - }) - - note, err := s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, pluginData.AlertID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer1+" reviewed the request", "note must contain a review author") - assert.Contains(t, note.Note, "Resolution: APPROVED", "note must contain an approval resolution") - assert.Contains(t, note.Note, "Reason: okay", "note must contain an approval reason") - - note, err = s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, pluginData.AlertID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer2+" reviewed the request", "note must contain a review author") - assert.Contains(t, note.Note, "Resolution: APPROVED", "note must contain a approval resolution") - assert.Contains(t, note.Note, "Reason: not okay", "note must contain a denial reason") -} - -func (s *OpsgenieSuite) TestApprovalByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - s.startApp() - - req := s.createAccessRequest() - - alert, err := s.fakeOpsgenie.CheckNewAlert(s.Context()) - require.NoError(t, err, "no new alerts stored") - - err = s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }) - require.NoError(t, err) - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "finally okay", - }) - require.NoError(t, err) - - note, err := s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer1+" reviewed the request", "note must contain a review author") - - note, err = s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer2+" reviewed the request", "note must contain a review author") - - data := s.checkPluginData(req.GetName(), func(data PluginData) bool { - return data.ReviewsCount == 2 && data.Resolution.Tag != Unresolved - }) - assert.Equal(t, Resolution{Tag: ResolvedApproved, Reason: "finally okay"}, data.Resolution) - - note, err = s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, "Access request has been approved") - assert.Contains(t, note.Note, "Reason: finally okay") - - alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(s.Context()) - require.NoError(t, err) - assert.Equal(t, "resolved", alertUpdate.Status) -} - -func (s *OpsgenieSuite) TestDenialByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - s.startApp() - - req := s.createAccessRequest() - - alert, err := s.fakeOpsgenie.CheckNewAlert(s.Context()) - require.NoError(t, err, "no new alerts stored") - - err = s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_DENIED, - Created: time.Now(), - Reason: "not okay", - }) - require.NoError(t, err) - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_DENIED, - Created: time.Now(), - Reason: "finally not okay", - }) - require.NoError(t, err) - - note, err := s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer1+" reviewed the request", "note must contain a review author") - - note, err = s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, s.userNames.reviewer2+" reviewed the request", "note must contain a review author") - - data := s.checkPluginData(req.GetName(), func(data PluginData) bool { - return data.ReviewsCount == 2 && data.Resolution.Tag != Unresolved - }) - assert.Equal(t, Resolution{Tag: ResolvedDenied, Reason: "finally not okay"}, data.Resolution) - - note, err = s.fakeOpsgenie.CheckNewAlertNote(s.Context()) - require.NoError(t, err) - assert.Equal(t, alert.ID, note.AlertID) - assert.Contains(t, note.Note, "Access request has been denied") - assert.Contains(t, note.Note, "Reason: finally not okay") - - alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(s.Context()) - require.NoError(t, err) - assert.Equal(t, "resolved", alertUpdate.Status) -} diff --git a/integrations/access/opsgenie/fake_opsgenie_test.go b/integrations/access/opsgenie/testlib/fake_opsgenie.go similarity index 68% rename from integrations/access/opsgenie/fake_opsgenie_test.go rename to integrations/access/opsgenie/testlib/fake_opsgenie.go index 87fcf06ac2507..2d63adf5c06b0 100644 --- a/integrations/access/opsgenie/fake_opsgenie_test.go +++ b/integrations/access/opsgenie/testlib/fake_opsgenie.go @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package opsgenie +package testlib import ( "context" @@ -35,6 +35,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/opsgenie" "github.com/gravitational/teleport/integrations/lib/stringset" ) @@ -44,8 +45,8 @@ type FakeOpsgenie struct { objects sync.Map // Alerts alertIDCounter uint64 - newAlerts chan Alert - alertUpdates chan Alert + newAlerts chan opsgenie.Alert + alertUpdates chan opsgenie.Alert // Alert notes newAlertNotes chan FakeAlertNote // Responders @@ -72,15 +73,15 @@ type fakeResponderByNameKey string type FakeAlertNote struct { AlertID string - AlertNote + opsgenie.AlertNote } func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { router := httprouter.New() - opsgenie := &FakeOpsgenie{ - newAlerts: make(chan Alert, concurrency), - alertUpdates: make(chan Alert, concurrency), + mock := &FakeOpsgenie{ + newAlerts: make(chan opsgenie.Alert, concurrency), + alertUpdates: make(chan opsgenie.Alert, concurrency), newAlertNotes: make(chan FakeAlertNote, concurrency*3), // for any alert there could be 1-3 notes srv: httptest.NewServer(router), } @@ -89,40 +90,40 @@ func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusCreated) - var alert Alert + var alert opsgenie.Alert err := json.NewDecoder(r.Body).Decode(&alert) panicIf(err) - alert.ID = fmt.Sprintf("alert-%v", atomic.AddUint64(&opsgenie.alertIDCounter, 1)) + alert.ID = fmt.Sprintf("alert-%v", atomic.AddUint64(&mock.alertIDCounter, 1)) alert.Status = types.RequestState_PENDING.String() - opsgenie.StoreAlert(alert) - opsgenie.newAlerts <- alert + mock.StoreAlert(alert) + mock.newAlerts <- alert - err = json.NewEncoder(rw).Encode(AlertResult{Alert: alert}) + err = json.NewEncoder(rw).Encode(opsgenie.AlertResult{Alert: alert}) panicIf(err) }) router.POST("/v2/alerts/:alertID/close", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { alertID := ps.ByName("alertID") - var body AlertNote + var body opsgenie.AlertNote err := json.NewDecoder(r.Body).Decode(&body) panicIf(err) - note := opsgenie.StoreAlertNote(alertID, AlertNote{Note: body.Note}) + note := mock.StoreAlertNote(alertID, opsgenie.AlertNote{Note: body.Note}) - opsgenie.newAlertNotes <- FakeAlertNote{AlertNote: note, AlertID: alertID} + mock.newAlertNotes <- FakeAlertNote{AlertNote: note, AlertID: alertID} err = json.NewEncoder(rw).Encode(note) panicIf(err) - alert, found := opsgenie.GetAlert(alertID) + alert, found := mock.GetAlert(alertID) if !found { rw.WriteHeader(http.StatusNotFound) return } alert.Status = "resolved" - opsgenie.StoreAlert(alert) - opsgenie.alertUpdates <- alert + mock.StoreAlert(alert) + mock.alertUpdates <- alert }) router.POST("/v2/alerts/:alertID/notes", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -131,18 +132,18 @@ func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { alertID := ps.ByName("alertID") - var body AlertNote + var body opsgenie.AlertNote err := json.NewDecoder(r.Body).Decode(&body) panicIf(err) - note := opsgenie.StoreAlertNote(alertID, AlertNote{Note: body.Note}) + note := mock.StoreAlertNote(alertID, opsgenie.AlertNote{Note: body.Note}) - opsgenie.newAlertNotes <- FakeAlertNote{AlertNote: note, AlertID: alertID} + mock.newAlertNotes <- FakeAlertNote{AlertNote: note, AlertID: alertID} err = json.NewEncoder(rw).Encode(note) panicIf(err) }) - return opsgenie + return mock } func (s *FakeOpsgenie) URL() string { @@ -156,27 +157,27 @@ func (s *FakeOpsgenie) Close() { close(s.newAlertNotes) } -func (s *FakeOpsgenie) GetResponder(id string) (Responder, bool) { +func (s *FakeOpsgenie) GetResponder(id string) (opsgenie.Responder, bool) { if obj, ok := s.objects.Load(id); ok { - responder, ok := obj.(Responder) + responder, ok := obj.(opsgenie.Responder) return responder, ok } - return Responder{}, false + return opsgenie.Responder{}, false } -func (s *FakeOpsgenie) GetResponderByName(name string) (Responder, bool) { +func (s *FakeOpsgenie) GetResponderByName(name string) (opsgenie.Responder, bool) { if obj, ok := s.objects.Load(fakeResponderByNameKey(strings.ToLower(name))); ok { - responder, ok := obj.(Responder) + responder, ok := obj.(opsgenie.Responder) return responder, ok } - return Responder{}, false + return opsgenie.Responder{}, false } -func (s *FakeOpsgenie) StoreResponder(responder Responder) Responder { +func (s *FakeOpsgenie) StoreResponder(responder opsgenie.Responder) opsgenie.Responder { byNameKey := fakeResponderByNameKey(strings.ToLower(responder.Name)) if responder.ID == "" { if obj, ok := s.objects.Load(byNameKey); ok { - responder.ID = obj.(Responder).ID + responder.ID = obj.(opsgenie.Responder).ID } else { responder.ID = fmt.Sprintf("responder-%v", atomic.AddUint64(&s.responderIDCounter, 1)) } @@ -186,15 +187,15 @@ func (s *FakeOpsgenie) StoreResponder(responder Responder) Responder { return responder } -func (s *FakeOpsgenie) GetAlert(id string) (Alert, bool) { +func (s *FakeOpsgenie) GetAlert(id string) (opsgenie.Alert, bool) { if obj, ok := s.objects.Load(id); ok { - alert, ok := obj.(Alert) + alert, ok := obj.(opsgenie.Alert) return alert, ok } - return Alert{}, false + return opsgenie.Alert{}, false } -func (s *FakeOpsgenie) StoreAlert(alert Alert) Alert { +func (s *FakeOpsgenie) StoreAlert(alert opsgenie.Alert) opsgenie.Alert { if alert.ID == "" { alert.ID = fmt.Sprintf("alert-%v", atomic.AddUint64(&s.alertIDCounter, 1)) } @@ -202,26 +203,26 @@ func (s *FakeOpsgenie) StoreAlert(alert Alert) Alert { return alert } -func (s *FakeOpsgenie) StoreAlertNote(alertID string, note AlertNote) AlertNote { +func (s *FakeOpsgenie) StoreAlertNote(alertID string, note opsgenie.AlertNote) opsgenie.AlertNote { s.objects.Store(alertID+note.Note, note) return note } -func (s *FakeOpsgenie) CheckNewAlert(ctx context.Context) (Alert, error) { +func (s *FakeOpsgenie) CheckNewAlert(ctx context.Context) (opsgenie.Alert, error) { select { case alert := <-s.newAlerts: return alert, nil case <-ctx.Done(): - return Alert{}, trace.Wrap(ctx.Err()) + return opsgenie.Alert{}, trace.Wrap(ctx.Err()) } } -func (s *FakeOpsgenie) CheckAlertUpdate(ctx context.Context) (Alert, error) { +func (s *FakeOpsgenie) CheckAlertUpdate(ctx context.Context) (opsgenie.Alert, error) { select { case alert := <-s.alertUpdates: return alert, nil case <-ctx.Done(): - return Alert{}, trace.Wrap(ctx.Err()) + return opsgenie.Alert{}, trace.Wrap(ctx.Err()) } } diff --git a/integrations/access/opsgenie/testlib/helpers.go b/integrations/access/opsgenie/testlib/helpers.go new file mode 100644 index 0000000000000..a600fedc52f7c --- /dev/null +++ b/integrations/access/opsgenie/testlib/helpers.go @@ -0,0 +1,40 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "context" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/integrations/access/opsgenie" +) + +func (s *OpsgenieSuite) checkPluginData(ctx context.Context, reqID string, cond func(opsgenie.PluginData) bool) opsgenie.PluginData { + t := s.T() + t.Helper() + + for { + rawData, err := s.Ruler().PollAccessRequestPluginData(ctx, "opsgenie", reqID) + require.NoError(t, err) + if data := opsgenie.DecodePluginData(rawData); cond(data) { + return data + } + } +} diff --git a/integrations/access/opsgenie/testlib/oss_integration_test.go b/integrations/access/opsgenie/testlib/oss_integration_test.go new file mode 100644 index 0000000000000..2b2846285fe79 --- /dev/null +++ b/integrations/access/opsgenie/testlib/oss_integration_test.go @@ -0,0 +1,36 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/gravitational/teleport/integrations/lib/testing/integration" +) + +func TestOpsgeniePluginOSS(t *testing.T) { + opsgenieSuite := &OpsgenieSuite{ + AccessRequestSuite: &integration.AccessRequestSuite{ + AuthHelper: &integration.OSSAuthHelper{}, + }, + } + suite.Run(t, opsgenieSuite) +} diff --git a/integrations/access/opsgenie/testlib/suite.go b/integrations/access/opsgenie/testlib/suite.go new file mode 100644 index 0000000000000..183422c0100f7 --- /dev/null +++ b/integrations/access/opsgenie/testlib/suite.go @@ -0,0 +1,384 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testlib + +import ( + "context" + "runtime" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/access/opsgenie" + "github.com/gravitational/teleport/integrations/access/pagerduty" + "github.com/gravitational/teleport/integrations/lib/logger" + "github.com/gravitational/teleport/integrations/lib/testing/integration" +) + +const ( + NotifyServiceName = "Teleport Notifications" + NotifyServiceAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifyServicesLabel + ResponderName1 = "Responder 1" + ResponderName2 = "Responder 2" + ResponderName3 = "Responder 3" +) + +// OpsgenieSuite is the OpsGenie access plugin test suite. +// It implements the testify.TestingSuite interface. +type OpsgenieSuite struct { + *integration.AccessRequestSuite + appConfig opsgenie.Config + raceNumber int + fakeOpsgenie *FakeOpsgenie + + ogNotifyResponder opsgenie.Responder + ogResponder1 opsgenie.Responder + ogResponder2 opsgenie.Responder + ogResponder3 opsgenie.Responder +} + +// SetupTest starts a fake OpsGenie and generates the plugin configuration. +// It also configures the role notifications for OpsGenie notifications and +// automatic approval. +// It is run for each test. +func (s *OpsgenieSuite) SetupTest() { + t := s.T() + ctx := context.Background() + + err := logger.Setup(logger.Config{Severity: "debug"}) + require.NoError(t, err) + s.raceNumber = 2 * runtime.GOMAXPROCS(0) + + s.fakeOpsgenie = NewFakeOpsgenie(s.raceNumber) + t.Cleanup(s.fakeOpsgenie.Close) + + // This service should be notified for every access request. + s.ogNotifyResponder = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ + Name: NotifyServiceName, + }) + s.AnnotateRequesterRoleAccessRequests( + ctx, + NotifyServiceAnnotation, + []string{NotifyServiceName}, + ) + + // Responder 1 and 2 are on-call and should be automatically approved. + // Responder 3 is not. + s.ogResponder1 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ + Name: ResponderName1, + }) + s.ogResponder2 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ + Name: ResponderName2, + }) + s.ogResponder3 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ + Name: ResponderName3, + }) + s.AnnotateRequesterRoleAccessRequests( + ctx, + pagerduty.ServicesDefaultAnnotation, + []string{ResponderName1, ResponderName2}, + ) + + var conf opsgenie.Config + conf.Teleport = s.TeleportConfig() + conf.ClientConfig.APIEndpoint = s.fakeOpsgenie.URL() + + s.appConfig = conf +} + +// startApp starts the OpsGenie plugin, waits for it to become ready and returns. +func (s *OpsgenieSuite) startApp() { + t := s.T() + t.Helper() + + app, err := opsgenie.NewOpsgenieApp(context.Background(), &s.appConfig) + require.NoError(t, err) + s.RunAndWaitReady(t, app) +} + +// TestAlertCreation validates that an alert is created to the service +// specified in the role's annotation. +func (s *OpsgenieSuite) TestAlertCreation() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test execution: create an access request + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + // Validate the alert has been created in OpsGenie and its ID is stored in + // the plugin_data. + pluginData := s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.AlertID != "" + }) + + alert, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + assert.Equal(t, alert.ID, pluginData.AlertID) +} + +// TestApproval tests that when a request is approved, its corresponding alert +// is updated to reflect the new request state and a note is added to the alert. +func (s *OpsgenieSuite) TestApproval() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + alert, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we approve the request + err = s.Ruler().ApproveAccessRequest(ctx, req.GetName(), "okay") + require.NoError(t, err) + + // Validating the plugin added a note to the alert describing the review. + note, err := s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, "Access request has been approved") + assert.Contains(t, note.Note, "Reason: okay") + + // Validating the plugin resolved the alert. + alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestDenial tests that when a request is denied, its corresponding alert +// is updated to reflect the new request state and a note is added to the alert. +func (s *OpsgenieSuite) TestDenial() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil) + + alert, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we deny the request + err = s.Ruler().DenyAccessRequest(ctx, req.GetName(), "not okay") + require.NoError(t, err) + + // Validating the plugin added a note to the alert describing the review. + note, err := s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, "Access request has been denied") + assert.Contains(t, note.Note, "Reason: not okay") + + // Validating the plugin resolved the alert. + alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestReviewNotes tests that alert notes are sent after the access request +// is reviewed. Each review should create a new note. +func (s *OpsgenieSuite) TestReviewNotes() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + if !s.TeleportFeatures().AdvancedAccessWorkflows { + t.Skip("Doesn't work in OSS version") + } + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + // Test execution: we submit two reviews + err := s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer1UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "okay", + }) + require.NoError(t, err) + + err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer2UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "not okay", + }) + require.NoError(t, err) + + // Validate alert notes were sent with the correct content. + pluginData := s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.AlertID != "" && data.ReviewsCount == 2 + }) + + note, err := s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, pluginData.AlertID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author") + assert.Contains(t, note.Note, "Resolution: APPROVED", "note must contain an approval resolution") + assert.Contains(t, note.Note, "Reason: okay", "note must contain an approval reason") + + note, err = s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, pluginData.AlertID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author") + assert.Contains(t, note.Note, "Resolution: APPROVED", "note must contain a approval resolution") + assert.Contains(t, note.Note, "Reason: not okay", "note must contain a denial reason") +} + +// TestApprovalByReview tests that the alert is annotated and resolved after the +// access request approval threshold is reached. +func (s *OpsgenieSuite) TestApprovalByReview() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + if !s.TeleportFeatures().AdvancedAccessWorkflows { + t.Skip("Doesn't work in OSS version") + } + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + alert, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we submit two reviews + err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer1UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "okay", + }) + require.NoError(t, err) + + err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer2UserName, + ProposedState: types.RequestState_APPROVED, + Created: time.Now(), + Reason: "finally okay", + }) + require.NoError(t, err) + + // Validate alert notes were sent with the correct content. + note, err := s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author") + + note, err = s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author") + + // Validate the alert got resolved. + data := s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.ReviewsCount == 2 && data.Resolution.Tag != opsgenie.Unresolved + }) + assert.Equal(t, opsgenie.Resolution{Tag: opsgenie.ResolvedApproved, Reason: "finally okay"}, data.Resolution) + + note, err = s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, "Access request has been approved") + assert.Contains(t, note.Note, "Reason: finally okay") + + alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +} + +// TestDenialByReview tests that the alert is annotated and resolved after the +// access request denial threshold is reached. +func (s *OpsgenieSuite) TestDenialByReview() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + if !s.TeleportFeatures().AdvancedAccessWorkflows { + t.Skip("Doesn't work in OSS version") + } + + s.startApp() + + // Test setup: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + alert, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Test execution: we submit two reviews + err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer1UserName, + ProposedState: types.RequestState_DENIED, + Created: time.Now(), + Reason: "not okay", + }) + require.NoError(t, err) + + err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{ + Author: integration.Reviewer2UserName, + ProposedState: types.RequestState_DENIED, + Created: time.Now(), + Reason: "finally not okay", + }) + require.NoError(t, err) + + // Validate alert notes were sent with the correct content. + note, err := s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author") + + note, err = s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author") + + // Validate the alert got resolved. + data := s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.ReviewsCount == 2 && data.Resolution.Tag != opsgenie.Unresolved + }) + assert.Equal(t, opsgenie.Resolution{Tag: opsgenie.ResolvedDenied, Reason: "finally not okay"}, data.Resolution) + + note, err = s.fakeOpsgenie.CheckNewAlertNote(ctx) + require.NoError(t, err) + assert.Equal(t, alert.ID, note.AlertID) + assert.Contains(t, note.Note, "Access request has been denied") + assert.Contains(t, note.Note, "Reason: finally not okay") + + alertUpdate, err := s.fakeOpsgenie.CheckAlertUpdate(ctx) + require.NoError(t, err) + assert.Equal(t, "resolved", alertUpdate.Status) +}