From 1cfd0cda6f8244963a1958d9431e9298213c2f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20BABAL?= Date: Thu, 28 Sep 2023 13:56:45 +0300 Subject: [PATCH] Cloud Slack E2e Tests (#1205) --- .golangci.yml | 3 +- Makefile | 4 +- helm/botkube/e2e-cloud-test-values.yaml | 148 +++++++ helm/botkube/e2e-test-values.yaml | 2 +- test/botkubex/botkube_cli_helpers.go | 69 ++++ test/commplatform/discord_tester.go | 12 + test/commplatform/generic.go | 20 +- test/commplatform/slack_tester.go | 254 +++++++++--- test/e2e/bots_test.go | 433 +++++++++++++------- test/e2e/gql_client.go | 500 ++++++++++++++++++++++++ test/e2e/slack_helpers_test.go | 4 +- 11 files changed, 1257 insertions(+), 192 deletions(-) create mode 100644 helm/botkube/e2e-cloud-test-values.yaml create mode 100644 test/botkubex/botkube_cli_helpers.go create mode 100644 test/e2e/gql_client.go diff --git a/.golangci.yml b/.golangci.yml index 9b49f5942..e68d7cb79 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,7 +47,8 @@ linters-settings: local-prefixes: github.com/kubeshop/botkube gocyclo: # https://github.com/kubeshop/botkube/issues/745 - min-complexity: 51 + # Merging slack and discord test cases increased cyclo due to if conditions + min-complexity: 55 revive: rules: # Disable warns about capitalized and ended with punctuation error messages diff --git a/Makefile b/Makefile index 56b8bb2b0..65c3a14ad 100644 --- a/Makefile +++ b/Makefile @@ -18,10 +18,10 @@ test: system-check @go test -v -race ./... test-integration-slack: system-check - @go test -v -tags=integration -race -count=1 ./test/e2e/... -run "TestSlack" + @go test -timeout=20m -v -tags=integration -race -count=1 ./test/e2e/... -run "TestSlack" test-integration-discord: system-check - @go test -v -tags=integration -race -count=1 ./test/e2e/... -run "TestDiscord" + @go test -timeout=20m -v -tags=integration -race -count=1 ./test/e2e/... -run "TestDiscord" test-cli-migration-e2e: system-check @go test -v -tags=migration -race -count=1 ./test/e2e/... diff --git a/helm/botkube/e2e-cloud-test-values.yaml b/helm/botkube/e2e-cloud-test-values.yaml new file mode 100644 index 000000000..e5c7f111d --- /dev/null +++ b/helm/botkube/e2e-cloud-test-values.yaml @@ -0,0 +1,148 @@ +analytics: + disable: true + +rbac: + create: true + rules: + - apiGroups: [ "*" ] + resources: [ "*" ] + verbs: [ "get", "watch", "list" ] # defaults + staticGroupName: "botkube-plugins-default" + +extraObjects: + + # Group 'kubectl-first-channel': permissions for kubectl for first channel + ## namespace scoped permissions + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: &kubectl-wait + name: kubectl-first-channel-namespaced-perms + labels: + app.kubernetes.io/instance: botkube-e2e-test + rules: + - apiGroups: [ "apps" ] + resources: [ "deployments" ] + verbs: [ "get","watch","list" ] + - apiGroups: [ "" ] + resources: [ "configmaps", "pods" ] + verbs: [ "get", "watch", "list" ] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + <<: *kubectl-wait + namespace: botkube + roleRef: &kubectl-wait-role + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kubectl-first-channel-namespaced-perms + subjects: &kubectl-first-channel-subject + - kind: User + name: kubectl-first-channel + apiGroup: rbac.authorization.k8s.io + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + <<: *kubectl-wait + namespace: default + roleRef: *kubectl-wait-role + subjects: *kubectl-first-channel-subject + + ### cluster permissions + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: &kubectl-deploy-all-meta + name: kc-first-channel-cluster-perms + labels: + app.kubernetes.io/instance: botkube-e2e-test + rules: + - apiGroups: [ "apps" ] + resources: [ "deployments" ] + verbs: [ "get", "list" ] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: *kubectl-deploy-all-meta + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kc-first-channel-cluster-perms + subjects: *kubectl-first-channel-subject + + # Group 'kc-exec-only' + ## exec only for default and botkube namespaces: + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: &kc-exec-only-meta + name: kc-exec-only + labels: + app.kubernetes.io/instance: botkube-e2e-test + rules: + - apiGroups: [ "" ] + resources: [ "pods/exec" ] + verbs: [ "create" ] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + <<: *kc-exec-only-meta + namespace: botkube + roleRef: &kc-exec-only-role + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kc-exec-only + subjects: &kc-exec-only-subject + - kind: User + name: kc-exec-only + apiGroup: rbac.authorization.k8s.io + - apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + <<: *kc-exec-only-meta + namespace: default + roleRef: *kc-exec-only-role + subjects: *kc-exec-only-subject + + # Group 'kc-label-svc-all': + ## namespace scoped permissions + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: &kc-label-svc-all-meta + name: kc-label-svc-all + labels: + app.kubernetes.io/instance: botkube-e2e-test + rules: + - apiGroups: [ "" ] + resources: [ "services" ] + verbs: [ "get", "patch" ] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: *kc-label-svc-all-meta + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kc-label-svc-all + subjects: + - kind: User + name: kc-label-svc-all + apiGroup: rbac.authorization.k8s.io + + # Group 'rbac-with-static-mapping': + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: &k8s-cm-watch-meta + name: kc-watch-cm + labels: + app.kubernetes.io/instance: botkube-e2e-test + rules: + - apiGroups: [ "" ] + resources: [ "configmaps" ] + verbs: [ "watch", "list" ] + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: *k8s-cm-watch-meta + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kc-watch-cm + subjects: + - kind: Group + name: kc-watch-cm + apiGroup: rbac.authorization.k8s.io diff --git a/helm/botkube/e2e-test-values.yaml b/helm/botkube/e2e-test-values.yaml index 434428d06..5c3f86150 100644 --- a/helm/botkube/e2e-test-values.yaml +++ b/helm/botkube/e2e-test-values.yaml @@ -293,7 +293,7 @@ executors: value: "kc-label-svc-all" 'other-plugins': - botkube/echo@v1.0.1-devel: + botkube/echo@v0.0.0-latest: enabled: true config: changeResponseToUpperCase: true diff --git a/test/botkubex/botkube_cli_helpers.go b/test/botkubex/botkube_cli_helpers.go new file mode 100644 index 000000000..38c9df787 --- /dev/null +++ b/test/botkubex/botkube_cli_helpers.go @@ -0,0 +1,69 @@ +package botkubex + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +type InstallParams struct { + BinaryPath string + HelmRepoDirectory string + ConfigProviderEndpoint string + ConfigProviderIdentifier string + ConfigProviderAPIKey string + ImageRegistry string + ImageRepository string + ImageTag string + PluginRestartPolicyThreshold int + PluginRestartHealthCheckIntervalSeconds int +} + +func Install(t *testing.T, params InstallParams) error { + //nolint:gosec // this is not production code + cmd := exec.Command(params.BinaryPath, "install", + "--auto-approve", + "--verbose", + fmt.Sprintf("--repo=%s", params.HelmRepoDirectory), + fmt.Sprintf("--values=%s/botkube/e2e-cloud-test-values.yaml", params.HelmRepoDirectory), + `--version=""`, // installer doesn't call Helm repo`index.yaml` when version is empty, so local Helm chart works as expected. + "--set", + fmt.Sprintf("image.registry=%s", params.ImageRegistry), + "--set", + fmt.Sprintf("image.repository=%s", params.ImageRepository), + "--set", + fmt.Sprintf("image.tag=%s", params.ImageTag), + "--set", + fmt.Sprintf("config.provider.endpoint=%s", params.ConfigProviderEndpoint), + "--set", + fmt.Sprintf("config.provider.identifier=%s", params.ConfigProviderIdentifier), + "--set", + fmt.Sprintf("extraEnv[0].name=%s", "BOTKUBE_PLUGINS_RESTART__POLICY_THRESHOLD"), + "--set-string", + fmt.Sprintf("extraEnv[0].value=%d", params.PluginRestartPolicyThreshold), + "--set", + fmt.Sprintf("extraEnv[1].name=%s", "BOTKUBE_PLUGINS_HEALTH__CHECK__INTERVAL"), + "--set-string", + fmt.Sprintf("extraEnv[1].value=%ds", params.PluginRestartHealthCheckIntervalSeconds), + "--set", + fmt.Sprintf("config.provider.apiKey=%s", params.ConfigProviderAPIKey)) + t.Logf("Executing command: %s", cmd.String()) + cmd.Env = os.Environ() + + o, err := cmd.CombinedOutput() + t.Logf("CLI output:\n%s", string(o)) + return err +} + +func Uninstall(t *testing.T, binaryPath string) { + //nolint:gosec // this is not production code + cmd := exec.Command(binaryPath, "uninstall", "--release-name", "botkube", "--namespace", "botkube", "--auto-approve") + cmd.Env = os.Environ() + + o, err := cmd.CombinedOutput() + t.Logf("CLI output:\n%s", string(o)) + require.NoError(t, err) +} diff --git a/test/commplatform/discord_tester.go b/test/commplatform/discord_tester.go index 29aa8dcb8..4e3c180af 100644 --- a/test/commplatform/discord_tester.go +++ b/test/commplatform/discord_tester.go @@ -442,6 +442,14 @@ func (d *DiscordTester) WaitForLastInteractiveMessagePostedEqualWithCustomRender }) } +func (d *DiscordTester) SetTimeout(timeout time.Duration) { + d.cfg.MessageWaitTimeout = timeout +} + +func (d *DiscordTester) Timeout() time.Duration { + return d.cfg.MessageWaitTimeout +} + func (d *DiscordTester) findUserID(t *testing.T, name string) string { t.Logf("Getting user %q...", name) res, err := d.cli.GuildMembersSearch(d.cfg.GuildID, name, 50) @@ -457,3 +465,7 @@ func (d *DiscordTester) findUserID(t *testing.T, name string) string { return "" } + +func (d *DiscordTester) ReplaceBotNamePlaceholder(msg *interactive.CoreMessage, clusterName string) { + msg.ReplaceBotNamePlaceholder(d.BotName()) +} diff --git a/test/commplatform/generic.go b/test/commplatform/generic.go index 7ed8a54fc..5db61694c 100644 --- a/test/commplatform/generic.go +++ b/test/commplatform/generic.go @@ -1,6 +1,7 @@ package commplatform import ( + "strings" "testing" "time" @@ -47,6 +48,9 @@ type BotDriver interface { WaitForInteractiveMessagePostedRecentlyEqual(userID string, channelID string, message interactive.CoreMessage) error WaitForLastInteractiveMessagePostedEqual(userID string, channelID string, message interactive.CoreMessage) error WaitForLastInteractiveMessagePostedEqualWithCustomRender(userID, channelID string, renderedMsg string) error + SetTimeout(timeout time.Duration) + Timeout() time.Duration + ReplaceBotNamePlaceholder(msg *interactive.CoreMessage, clusterName string) } type MessageAssertion func(content string) (bool, int, string) @@ -62,6 +66,20 @@ type ExpAttachmentInput struct { type DriverType string const ( - SlackBot DriverType = "slack" + SlackBot DriverType = "cloudSlack" DiscordBot DriverType = "discord" ) + +// AssertContains checks if message contains expected message +func AssertContains(expectedMessage string) MessageAssertion { + return func(msg string) (bool, int, string) { + return strings.Contains(msg, expectedMessage), 0, "" + } +} + +// AssertEquals checks if message is equal to expected message +func AssertEquals(expectedMessage string) MessageAssertion { + return func(msg string) (bool, int, string) { + return msg == expectedMessage, 0, "" + } +} diff --git a/test/commplatform/slack_tester.go b/test/commplatform/slack_tester.go index dc497a3e9..f99c707b7 100644 --- a/test/commplatform/slack_tester.go +++ b/test/commplatform/slack_tester.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "testing" "time" @@ -15,27 +16,39 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/wait" + "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/bot" "github.com/kubeshop/botkube/pkg/bot/interactive" "github.com/kubeshop/botkube/pkg/formatx" "github.com/kubeshop/botkube/test/diff" ) +const ( + slackInteractiveElementsMsgSuffix = ", with interactive elements" +) + +var slackLinks = regexp.MustCompile(`<(?Phttps://[^>]*)>`) + type SlackConfig struct { BotName string `envconfig:"default=botkube"` + CloudBotName string `envconfig:"default=botkubedev"` + CloudBasedTestEnabled bool `envconfig:"default=true"` TesterName string `envconfig:"default=tester"` AdditionalContextMessage string `envconfig:"optional"` TesterAppToken string `envconfig:"optional"` TesterBotToken string `envconfig:"optional"` CloudTesterAppToken string `envconfig:"optional"` + CloudTesterName string `envconfig:"default=tester2"` RecentMessagesLimit int `envconfig:"default=6"` - MessageWaitTimeout time.Duration `envconfig:"default=30s"` + MessageWaitTimeout time.Duration `envconfig:"default=50s"` } type SlackChannel struct { *slack.Channel } +type SlackMessageAssertion func(content slack.Message) (bool, int, string) + func (s *SlackChannel) ID() string { return s.Channel.ID } @@ -47,20 +60,25 @@ func (s *SlackChannel) Identifier() string { } type SlackTester struct { - cli *slack.Client - cfg SlackConfig - botUserID string - testerUserID string - channel Channel - secondChannel Channel - thirdChannel Channel - mdFormatter interactive.MDFormatter + cli *slack.Client + cfg SlackConfig + botUserID string + testerUserID string + channel Channel + secondChannel Channel + thirdChannel Channel + mdFormatter interactive.MDFormatter + configProviderApiKey string +} + +func (s *SlackTester) ReplaceBotNamePlaceholder(msg *interactive.CoreMessage, clusterName string) { + msg.ReplaceBotNamePlaceholder(s.BotName(), api.BotNameWithClusterName(clusterName)) } -func NewSlackTester(slackCfg SlackConfig) (BotDriver, error) { +func NewSlackTester(slackCfg SlackConfig, apiKey string) (BotDriver, error) { var token string - if slackCfg.TesterAppToken == "" && slackCfg.TesterBotToken == "" { - return nil, errors.New("slack tester token is not set") + if slackCfg.TesterAppToken == "" && slackCfg.TesterBotToken == "" && slackCfg.CloudTesterAppToken == "" { + return nil, errors.New("slack tester tokens are not set") } if slackCfg.TesterAppToken != "" { token = slackCfg.TesterAppToken @@ -68,6 +86,9 @@ func NewSlackTester(slackCfg SlackConfig) (BotDriver, error) { if slackCfg.TesterBotToken != "" { token = slackCfg.TesterBotToken } + if slackCfg.CloudBasedTestEnabled { + token = slackCfg.CloudTesterAppToken + } slackCli := slack.New(token) _, err := slackCli.AuthTest() @@ -77,16 +98,17 @@ func NewSlackTester(slackCfg SlackConfig) (BotDriver, error) { mdFormatter := interactive.NewMDFormatter(interactive.NewlineFormatter, func(msg string) string { return fmt.Sprintf("*%s*", msg) }) - return &SlackTester{cli: slackCli, cfg: slackCfg, mdFormatter: mdFormatter}, nil + return &SlackTester{cli: slackCli, cfg: slackCfg, mdFormatter: mdFormatter, configProviderApiKey: apiKey}, nil } func (s *SlackTester) InitUsers(t *testing.T) { t.Helper() - s.botUserID = s.findUserID(t, s.cfg.BotName) - assert.NotEmpty(t, s.botUserID, "could not find slack botUserID with name: %s", s.cfg.BotName) + botName := s.cfg.BotUsername() + s.botUserID = s.findUserID(t, botName) + assert.NotEmpty(t, s.botUserID, "could not find slack botUserID with name: %s", botName) - s.testerUserID = s.findUserID(t, s.cfg.TesterName) - assert.NotEmpty(t, s.testerUserID, "could not find slack testerUserID with name: %s", s.cfg.TesterName) + s.testerUserID = s.findUserID(t, s.cfg.CloudTesterName) + assert.NotEmpty(t, s.testerUserID, "could not find slack testerUserID with name: %s", s.cfg.CloudTesterName) } func (s *SlackTester) InitChannels(t *testing.T) []func() { @@ -152,7 +174,7 @@ func (s *SlackTester) PostInitialMessage(t *testing.T, channelName string) { } func (s *SlackTester) PostMessageToBot(t *testing.T, channel, command string) { - message := fmt.Sprintf("<@%s> %s", s.cfg.BotName, command) + message := fmt.Sprintf("<@%s> %s", s.cfg.BotUsername(), command) _, _, err := s.cli.PostMessage(channel, slack.MsgOptionText(message, false)) require.NoError(t, err) } @@ -165,7 +187,6 @@ func (s *SlackTester) InviteBotToChannel(t *testing.T, channelID string) { func (s *SlackTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expectedMsg string) error { return s.WaitForMessagePosted(userID, channelID, s.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { - msg = TrimSlackMsgTrailingLine(msg) if !strings.EqualFold(expectedMsg, msg) { count := diff.CountMatchBlock(expectedMsg, msg) msgDiff := diff.Diff(expectedMsg, msg) @@ -177,14 +198,14 @@ func (s *SlackTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expec func (s *SlackTester) WaitForLastMessageContains(userID, channelID, expectedMsgSubstring string) error { return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { - return strings.Contains(TrimSlackMsgTrailingLine(msg), expectedMsgSubstring), 0, "" + return strings.Contains(msg, expectedMsgSubstring), 0, "" }) } func (s *SlackTester) WaitForLastMessageEqual(userID, channelID, expectedMsg string) error { return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { - msg = TrimSlackMsgTrailingLine(msg) - msg = formatx.RemoveHyperlinks(msg) // normalize the message URLs + msg = formatx.RemoveHyperlinks(msg) // normalize the message URLs + msg = strings.ReplaceAll(msg, slackInteractiveElementsMsgSuffix, "") // remove interactive elements suffix if msg != expectedMsg { count := diff.CountMatchBlock(expectedMsg, msg) msgDiff := diff.Diff(expectedMsg, msg) @@ -246,7 +267,51 @@ func (s *SlackTester) WaitForMessagePosted(userID, channelID string, limitMessag } func (s *SlackTester) WaitForInteractiveMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { - return s.WaitForMessagePosted(userID, channelID, limitMessages, assertFn) + var fetchedMessages []slack.Message + var lastErr error + // SA1019 suggested `PollWithContextTimeout` does not exist + // nolint:staticcheck + err := wait.Poll(pollInterval, s.cfg.MessageWaitTimeout, func() (done bool, err error) { + historyRes, err := s.cli.GetConversationHistory(&slack.GetConversationHistoryParameters{ + ChannelID: channelID, Limit: limitMessages, + }) + if err != nil { + lastErr = err + return false, nil + } + + fetchedMessages = historyRes.Messages + for _, msg := range historyRes.Messages { + if msg.User != userID { + continue + } + + if len(msg.Blocks.BlockSet) == 0 { + continue + } + + ok, _, _ := assertFn(sPrintBlocks(s.normalizeSlackBlockSet(msg.Blocks))) + + if !ok { + continue + } + + return true, nil + } + + return false, nil + }) + if lastErr == nil { + lastErr = errors.New("message assertion function returned false") + } + if err != nil { + if wait.Interrupted(err) { + return fmt.Errorf("while waiting for condition: last error: %w; fetched messages: %s", lastErr, structDumper.Sdump(fetchedMessages)) + } + return err + } + + return nil } func (s *SlackTester) WaitForMessagePostedWithFileUpload(userID, channelID string, assertFn FileUploadAssertion) error { @@ -307,14 +372,12 @@ func (s *SlackTester) WaitForMessagePostedWithAttachment(userID, channelID strin // we don't support the attachment anymore, so content is available as normal message return s.WaitForMessagePosted(userID, channelID, limitMessages, func(content string) (bool, int, string) { // for now, we use old slack, so we send messages as a markdown - expMsg := renderer.MessageToMarkdown(interactive.CoreMessage{ + expMsg := normalizeAttachmentContent(renderer.MessageToMarkdown(interactive.CoreMessage{ Message: assertFn.Message, - }) - + })) if !expTime.IsZero() { - body, timestamp := s.cutLastLine(content) - content = body // update content, so timestamp doesn't impact static content assertion - + body, timestamp := s.trimAttachmentTimestamp(content) + content = normalizeAttachmentContent(body) gotEventTime, err := dateparse.ParseAny(timestamp) if err != nil { return false, 0, err.Error() @@ -336,29 +399,56 @@ func (s *SlackTester) WaitForMessagePostedWithAttachment(userID, channelID strin }) } -// TODO: This contains an implementation for socket mode slack apps. Once needed, you can see the already implemented -// functions here https://github.com/kubeshop/botkube/blob/abfeb95fa5f84ceb9b25a30159cdc3d17e130711/test/e2e/slack_driver_test.go#L289 func (s *SlackTester) WaitForInteractiveMessagePostedRecentlyEqual(userID, channelID string, msg interactive.CoreMessage) error { - renderedMsg := interactive.RenderMessage(s.mdFormatter, msg) - return s.WaitForMessagePosted(userID, channelID, s.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { - // Slack encloses URLs with `<` and `>`, since we need to remove them before assertion - msg = strings.NewReplacer("\n", "\n").Replace(msg) - if !strings.EqualFold(renderedMsg, msg) { - count := diff.CountMatchBlock(renderedMsg, msg) - msgDiff := diff.Diff(renderedMsg, msg) + printedBlocks := sPrintBlocks(bot.NewSlackRenderer().RenderAsSlackBlocks(msg)) + return s.WaitForInteractiveMessagePosted(userID, channelID, s.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { + if !strings.EqualFold(msg, printedBlocks) { + count := diff.CountMatchBlock(printedBlocks, msg) + msgDiff := diff.Diff(printedBlocks, msg) return false, count, msgDiff } return true, 0, "" }) } +func sPrintBlocks(blocks []slack.Block) string { + var builder strings.Builder + + for _, block := range blocks { + switch block.BlockType() { + case slack.MBTSection: + section := block.(*slack.SectionBlock) + builder.WriteString("::::") + builder.WriteString(fmt.Sprintf("section: %s", section.Text.Text)) + case slack.MBTDivider: + builder.WriteString("::::") + builder.WriteString("divider") + case slack.MBTAction: + action := block.(*slack.ActionBlock) + builder.WriteString("::::") + for _, element := range action.Elements.ElementSet { + switch element.ElementType() { + case slack.METButton: + button := element.(*slack.ButtonBlockElement) + builder.WriteString(fmt.Sprintf("action::button: %s <> %s <> %s", + button.Text.Text, + button.Value, + button.ActionID, + )) + } + } + } + } + builder.WriteString("::::") + return builder.String() +} + func (s *SlackTester) WaitForLastInteractiveMessagePostedEqual(userID, channelID string, msg interactive.CoreMessage) error { - renderedMsg := interactive.RenderMessage(s.mdFormatter, msg) - return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { - msg = strings.NewReplacer("\n", "\n").Replace(msg) - if !strings.EqualFold(renderedMsg, msg) { - count := diff.CountMatchBlock(renderedMsg, msg) - msgDiff := diff.Diff(renderedMsg, msg) + printedBlocks := sPrintBlocks(bot.NewSlackRenderer().RenderAsSlackBlocks(msg)) + return s.WaitForInteractiveMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { + if !strings.EqualFold(printedBlocks, msg) { + count := diff.CountMatchBlock(printedBlocks, msg) + msgDiff := diff.Diff(printedBlocks, msg) return false, count, msgDiff } return true, 0, "" @@ -377,6 +467,38 @@ func (s *SlackTester) WaitForLastInteractiveMessagePostedEqualWithCustomRender(u }) } +func (s *SlackTester) SetTimeout(timeout time.Duration) { + s.cfg.MessageWaitTimeout = timeout +} + +func (s *SlackTester) Timeout() time.Duration { + return s.cfg.MessageWaitTimeout +} + +func (s *SlackTester) normalizeSlackBlockSet(got slack.Blocks) []slack.Block { + for idx, item := range got.BlockSet { + switch item.BlockType() { + case slack.MBTSection: + item := item.(*slack.SectionBlock) + item.BlockID = "" // it's generated by SDK, so we don't compare it. + if item.Text != nil { + item.Text.Text = removeSlackLinksIndicators(item.Text.Text) + } + + got.BlockSet[idx] = item + case slack.MBTDivider: + item := item.(*slack.DividerBlock) + item.BlockID = "" // it's generated by SDK, so we don't compare it. + got.BlockSet[idx] = item + case slack.MBTAction: + item := item.(*slack.ActionBlock) + item.BlockID = "" // it's generated by SDK, so we don't compare it. + got.BlockSet[idx] = item + } + } + return got.BlockSet +} + func (s *SlackTester) findUserID(t *testing.T, name string) string { t.Log("Getting users...") res, err := s.cli.GetUsers() @@ -418,23 +540,37 @@ func (s *SlackTester) CreateChannel(t *testing.T, prefix string) (Channel, func( return &SlackChannel{channel}, cleanupFn } +func (s *SlackConfig) BotUsername() string { + if s.CloudBasedTestEnabled { + return s.CloudBotName + } + return s.BotName +} + func TrimSlackMsgTrailingLine(msg string) string { // There is always a `\n` on Slack messages due to Markdown formatting. // That should be replaced for RTM return strings.TrimSuffix(msg, "\n") } -func (s *SlackTester) cutLastLine(in string) (before string, after string) { - in = strings.TrimSpace(in) - if in == "" { - return "", "" - } - lastNewLine := strings.LastIndexAny(in, "\n") - if lastNewLine == -1 { - return in, "" - } +func normalizeAttachmentContent(msg string) string { + msg = strings.ReplaceAll(msg, " • ", "") + msg = strings.ReplaceAll(msg, "• ", "") + msg = strings.ReplaceAll(msg, "\n", " ") + msg = strings.ReplaceAll(msg, " *", " *") + msg = strings.ReplaceAll(msg, "*Fields* ", "") + msg = strings.ReplaceAll(msg, "*: ", ":* ") + msg = strings.TrimSuffix(msg, " ") + return msg +} - return in[:lastNewLine], in[lastNewLine+1:] +func (s *SlackTester) trimAttachmentTimestamp(in string) (string, string) { + msgParts := strings.Split(in, " 1 { + ts = strings.Split(msgParts[1], "^")[0] + } + return msgParts[0], ts } var emojiSlackMapping = map[string]string{ @@ -449,3 +585,13 @@ func replaceEmojiWithTags(content string) string { } return content } + +func removeSlackLinksIndicators(content string) string { + tpl := "$val" + + return slackLinks.ReplaceAllStringFunc(content, func(s string) string { + var result []byte + result = slackLinks.ExpandString(result, tpl, s, slackLinks.FindSubmatchIndex([]byte(s))) + return string(result) + }) +} diff --git a/test/e2e/bots_test.go b/test/e2e/bots_test.go index 03693772f..c3dab1cfe 100644 --- a/test/e2e/bots_test.go +++ b/test/e2e/bots_test.go @@ -14,6 +14,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/anthhub/forwarder" + "github.com/hasura/go-graphql-client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vrischmann/envconfig" @@ -33,11 +34,25 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/bot/interactive" "github.com/kubeshop/botkube/pkg/config" + "github.com/kubeshop/botkube/test/botkubex" "github.com/kubeshop/botkube/test/commplatform" "github.com/kubeshop/botkube/test/diff" "github.com/kubeshop/botkube/test/fake" ) +type ConfigProvider struct { + Endpoint string + ApiKey string + SlackWorkspaceTeamID string + ImageRepository string `envconfig:"default=kubeshop/pr/botkube"` + ImageRegistry string `envconfig:"default=ghcr.io"` + ImageTag string + HelmRepoDirectory string + BotkubeCliBinaryPath string + + Timeout time.Duration `envconfig:"default=15s"` +} + type Config struct { KubeconfigPath string `envconfig:"optional,KUBECONFIG"` Deployment struct { @@ -72,12 +87,12 @@ type Config struct { ClusterName string `envconfig:"default=sample"` Slack commplatform.SlackConfig Discord commplatform.DiscordConfig + ConfigProvider ConfigProvider ShortWaitTimeout time.Duration `envconfig:"default=7s"` } const ( - globalConfigMapName = "botkube-global-config" - testConfigMapName = "cm-watcher-trigger" + testConfigMapName = "cm-watcher-trigger" ) var ( @@ -93,6 +108,12 @@ var ( configMapLabels = map[string]string{ "test.botkube.io": "true", } + aliases = [][]string{ + {"kgp", "Get Pods", "kubectl get pods"}, + {"kgda", "Get Deployments", "kubectl get deployments -A"}, + {"e", "", "echo"}, + {"p", "", "ping"}, + } ) func TestSlack(t *testing.T) { @@ -130,7 +151,7 @@ func TestDiscord(t *testing.T) { func newBotDriver(cfg Config, driverType commplatform.DriverType) (commplatform.BotDriver, error) { switch driverType { case commplatform.SlackBot: - return commplatform.NewSlackTester(cfg.Slack) + return commplatform.NewSlackTester(cfg.Slack, cfg.ConfigProvider.ApiKey) case commplatform.DiscordBot: return commplatform.NewDiscordTester(cfg.Discord) } @@ -145,6 +166,7 @@ func runBotTest(t *testing.T, deployEnvSecondaryChannelIDName, deployEnvRbacChannelIDName string, ) { + botkubeDeploymentUninstalled := false t.Logf("Creating API client with provided token for %s...", driverType) botDriver, err := newBotDriver(appCfg, driverType) require.NoError(t, err) @@ -155,11 +177,15 @@ func runBotTest(t *testing.T, k8sCli, err := kubernetes.NewForConfig(k8sConfig) require.NoError(t, err) - t.Log("Starting plugin server...") - indexEndpoint, startServerFn := fake.NewPluginServer(appCfg.Plugins) - go func() { - require.NoError(t, startServerFn()) - }() + var indexEndpoint string + if botDriver.Type() == commplatform.DiscordBot { + t.Log("Starting plugin server...") + endpoint, startServerFn := fake.NewPluginServer(appCfg.Plugins) + indexEndpoint = endpoint + go func() { + require.NoError(t, startServerFn()) + }() + } t.Logf("Setting up test %s setup...", driverType) botDriver.InitUsers(t) @@ -179,15 +205,60 @@ func runBotTest(t *testing.T, botDriver.PostInitialMessage(t, currentChannel.Identifier()) botDriver.InviteBotToChannel(t, currentChannel.ID()) } + switch botDriver.Type() { + case commplatform.DiscordBot: + t.Log("Patching Deployment with test env variables...") + deployNsCli := k8sCli.AppsV1().Deployments(appCfg.Deployment.Namespace) + revertDeployFn := setTestEnvsForDeploy(t, appCfg, deployNsCli, botDriver.Type(), channels, indexEndpoint) + t.Cleanup(func() { revertDeployFn(t) }) - t.Log("Patching Deployment with test env variables...") - deployNsCli := k8sCli.AppsV1().Deployments(appCfg.Deployment.Namespace) - revertDeployFn := setTestEnvsForDeploy(t, appCfg, deployNsCli, botDriver.Type(), channels, indexEndpoint) - t.Cleanup(func() { revertDeployFn(t) }) + t.Log("Waiting for Deployment") + err = waitForDeploymentReady(deployNsCli, appCfg.Deployment.Name, appCfg.Deployment.WaitTimeout) + require.NoError(t, err) + case commplatform.SlackBot: + t.Log("Creating Botkube Cloud instance...") + gqlCli := NewClientForAPIKey(appCfg.ConfigProvider.Endpoint, appCfg.ConfigProvider.ApiKey) + appCfg.ClusterName = botDriver.Channel().Name() + deployment := gqlCli.MustCreateBasicDeploymentWithCloudSlack(t, appCfg.ClusterName, appCfg.ConfigProvider.SlackWorkspaceTeamID, botDriver.Channel().Name(), botDriver.SecondChannel().Name(), botDriver.ThirdChannel().Name()) + for _, alias := range aliases { + gqlCli.MustCreateAlias(t, alias[0], alias[1], alias[2], deployment.ID) + } + t.Cleanup(func() { + // We have a glitch on backend side and the logic below is a workaround for that. + // Tl;dr uninstalling Helm chart reports "DISCONNECTED" status, and deplyment deletion reports "DELETED" status. + // If we do these two things too quickly, we'll run into resource version mismatch in repository logic. + // Read more here: https://github.com/kubeshop/botkube-cloud/pull/486#issuecomment-1604333794 + for !botkubeDeploymentUninstalled { + t.Log("Waiting for Helm chart uninstallation, in order to proceed with deleting Botkube Cloud instance...") + time.Sleep(1 * time.Second) + } - t.Log("Waiting for Deployment") - err = waitForDeploymentReady(deployNsCli, appCfg.Deployment.Name, appCfg.Deployment.WaitTimeout) - require.NoError(t, err) + t.Log("Helm chart uninstalled. Waiting a bit...") + time.Sleep(3 * time.Second) // ugly, but at least we will be pretty sure we won't run into the resource version mismatch + + t.Log("Deleting Botkube Cloud instance...") + gqlCli.MustDeleteDeployment(t, graphql.ID(deployment.ID)) + }) + + err = botkubex.Install(t, botkubex.InstallParams{ + BinaryPath: appCfg.ConfigProvider.BotkubeCliBinaryPath, + HelmRepoDirectory: appCfg.ConfigProvider.HelmRepoDirectory, + ConfigProviderEndpoint: appCfg.ConfigProvider.Endpoint, + ConfigProviderIdentifier: deployment.ID, + ConfigProviderAPIKey: deployment.APIKey.Value, + ImageTag: appCfg.ConfigProvider.ImageTag, + ImageRegistry: appCfg.ConfigProvider.ImageRegistry, + ImageRepository: appCfg.ConfigProvider.ImageRepository, + PluginRestartPolicyThreshold: 1, + PluginRestartHealthCheckIntervalSeconds: 2, + }) + require.NoError(t, err) + t.Cleanup(func() { + t.Log("Uninstalling Helm chart...") + botkubex.Uninstall(t, appCfg.ConfigProvider.BotkubeCliBinaryPath) + botkubeDeploymentUninstalled = true + }) + } cmdHeader := func(command string) string { return fmt.Sprintf("`%s` on `%s`", command, appCfg.ClusterName) @@ -198,7 +269,7 @@ func runBotTest(t *testing.T, time.Sleep(appCfg.Discord.MessageWaitTimeout) t.Log("Waiting for interactive help") expMessage := interactive.NewHelpMessage(config.CommPlatformIntegration(botDriver.Type()), appCfg.ClusterName, []string{"botkube/helm", "botkube/kubectl"}).Build() - expMessage.ReplaceBotNamePlaceholder(botDriver.BotName()) + botDriver.ReplaceBotNamePlaceholder(&expMessage, appCfg.ClusterName) err = botDriver.WaitForInteractiveMessagePostedRecentlyEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expMessage, @@ -224,7 +295,7 @@ func runBotTest(t *testing.T, t.Run("Help", func(t *testing.T) { command := "help" expectedMessage := interactive.NewHelpMessage(config.CommPlatformIntegration(botDriver.Type()), appCfg.ClusterName, []string{"botkube/helm", "botkube/kubectl"}).Build() - expectedMessage.ReplaceBotNamePlaceholder(botDriver.BotName()) + botDriver.ReplaceBotNamePlaceholder(&expectedMessage, appCfg.ClusterName) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) err = botDriver.WaitForLastInteractiveMessagePostedEqual(botDriver.BotUserID(), botDriver.Channel().ID(), @@ -319,6 +390,9 @@ func runBotTest(t *testing.T, --version -o,--output`)) expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) + if botDriver.Type() == commplatform.SlackBot { + expectedMessage = fmt.Sprintf("%s %s", cmdHeader(command), expectedBody) + } botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) err := botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) @@ -353,6 +427,9 @@ func runBotTest(t *testing.T, Use "helm [command] --help" for more information about the command.`)) expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) + if botDriver.Type() == commplatform.SlackBot { + expectedMessage = fmt.Sprintf("%s %s", cmdHeader(command), expectedBody) + } botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) err := botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) assert.NoError(t, err) @@ -441,10 +518,18 @@ func runBotTest(t *testing.T, command := "show config --cluster-name non-existing" botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - t.Log("Ensuring bot didn't post anything new...") - time.Sleep(appCfg.Slack.MessageWaitTimeout) + expMessage := "Instance not found" + userId := botDriver.BotUserID() + + if botDriver.Type() == commplatform.DiscordBot { + t.Log("Ensuring bot didn't post anything new...") + time.Sleep(appCfg.Slack.MessageWaitTimeout) + expMessage = command + userId = botDriver.TesterUserID() + } + // Same expected message as before - err = botDriver.WaitForLastMessageContains(botDriver.TesterUserID(), botDriver.Channel().ID(), command) + err = botDriver.WaitForLastMessageContains(userId, botDriver.Channel().ID(), expMessage) assert.NoError(t, err) }) }) @@ -476,14 +561,20 @@ func runBotTest(t *testing.T, t.Run("Get Configmap", func(t *testing.T) { command := fmt.Sprintf("kubectl get configmap -n %s", appCfg.Deployment.Namespace) - assertionFn := func(msg string) (bool, int, string) { - return strings.Contains(msg, heredoc.Doc(fmt.Sprintf("`%s` on `%s`", command, appCfg.ClusterName))) && - strings.Contains(msg, "kube-root-ca.crt") && - strings.Contains(msg, "botkube-global-config"), 0, "" + assertConfigMaps := func(msg string) bool { + return strings.Contains(msg, "kube-root-ca.crt") && strings.Contains(msg, "botkube-global-config") + } + + if botDriver.Type() == commplatform.SlackBot { + assertConfigMaps = func(msg string) bool { + return strings.Contains(msg, "kube-root-ca.crt") + } } botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, func(msg string) (bool, int, string) { + return strings.Contains(msg, heredoc.Doc(fmt.Sprintf("`%s` on `%s`", command, appCfg.ClusterName))) && assertConfigMaps(msg), 0, "" + }) assert.NoError(t, err) }) @@ -501,7 +592,7 @@ func runBotTest(t *testing.T, }) t.Run("Receive large output as plaintext file with executor command as message", func(t *testing.T) { - command := fmt.Sprintf("kubectl get configmap %s -o yaml -n %s", globalConfigMapName, appCfg.Deployment.Namespace) + command := fmt.Sprintf("kubectl get pod -o yaml -n %s", appCfg.Deployment.Namespace) fileUploadAssertionFn := func(title, mimetype string) bool { return title == "Response.txt" && strings.Contains(mimetype, "text/plain") } @@ -575,10 +666,16 @@ func runBotTest(t *testing.T, t.Run("Exec (the kubectl which is disabled)", func(t *testing.T) { command := fmt.Sprintf("kubectl exec deploy/%s -n %s -- date", appCfg.Deployment.Name, appCfg.Deployment.Namespace) expectedBody := codeBlock(heredoc.Docf(` + Error from server (Forbidden): pods "botkube-pod" is forbidden: User "kubectl-first-channel" cannot create resource "pods/exec" in API group "" in the namespace "botkube" + + exit status 1`)) + if botDriver.Type() == commplatform.DiscordBot { + expectedBody = codeBlock(heredoc.Docf(` Defaulted container "botkube" out of: botkube, cfg-watcher Error from server (Forbidden): pods "botkube-pod" is forbidden: User "kubectl-first-channel" cannot create resource "pods/exec" in API group "" in the namespace "botkube" exit status 1`)) + } expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) @@ -663,7 +760,11 @@ func runBotTest(t *testing.T, expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.SecondChannel().Identifier(), command) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.SecondChannel().ID(), 5, commplatform.AssertContains(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + } assert.NoError(t, err) t.Log("Starting notifier in second channel...") @@ -672,7 +773,12 @@ func runBotTest(t *testing.T, expectedMessage = fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.SecondChannel().Identifier(), command) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.SecondChannel().ID(), 6, commplatform.AssertContains(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + } require.NoError(t, err) cfgMapCli := k8sCli.CoreV1().ConfigMaps(appCfg.Deployment.Namespace) @@ -716,13 +822,27 @@ func runBotTest(t *testing.T, }, }, } - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 2, expAttachmentIn) + if botDriver.Type() == commplatform.SlackBot { + // In cloud-based tests, after resource change in cloud, we can see extra messages as follows; + // v1/configmaps created ... <== This is the expected message + // Configuration reload requested... + // My watch has ended for cluster... + // Newer version (v1.3.0) of Botkube is ... + // My watch begins for cluster 'test-first-30aee50d-fac7-47ca-8... + // Which means, we need to wait for 5 messages in total. + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 5, expAttachmentIn) + } else { + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 2, expAttachmentIn) + } require.NoError(t, err) t.Log("Ensuring bot didn't post anything new in second channel...") - time.Sleep(appCfg.Slack.MessageWaitTimeout) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.SecondChannel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + } require.NoError(t, err) t.Log("Updating ConfigMap for not watched field...") @@ -734,9 +854,17 @@ func runBotTest(t *testing.T, t.Log("Ensuring bot didn't post anything new...") time.Sleep(appCfg.Slack.MessageWaitTimeout) - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 2, expAttachmentIn) + limitMessages := 2 + if botDriver.Type() == commplatform.SlackBot { + limitMessages = 6 + } + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), limitMessages, expAttachmentIn) require.NoError(t, err) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.SecondChannel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.SecondChannel().ID(), expectedMessage) + } require.NoError(t, err) t.Log("Updating ConfigMap for observed field...") @@ -778,7 +906,12 @@ func runBotTest(t *testing.T, expectedMessage = fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + waitForRestart(t, botDriver, botDriver.BotUserID(), botDriver.Channel().ID(), appCfg.ClusterName) + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + } assert.NoError(t, err) t.Log("Getting notifier status from second channel...") @@ -810,7 +943,11 @@ func runBotTest(t *testing.T, t.Log("Ensuring bot didn't post anything new on first channel...") time.Sleep(appCfg.Slack.MessageWaitTimeout) // Same expected message as before - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + } require.NoError(t, err) secondCMUpdate := commplatform.ExpAttachmentInput{ @@ -842,9 +979,17 @@ func runBotTest(t *testing.T, expectedMessage = fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + } require.NoError(t, err) + if botDriver.Type() == commplatform.SlackBot { + waitForRestart(t, botDriver, botDriver.BotUserID(), botDriver.Channel().ID(), appCfg.ClusterName) + } + t.Log("Creating and deleting ignored ConfigMap") ignoredCfgMap := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -862,7 +1007,11 @@ func runBotTest(t *testing.T, t.Log("Ensuring bot didn't post anything new...") time.Sleep(appCfg.Slack.MessageWaitTimeout) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + if botDriver.Type() == commplatform.SlackBot { + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 5, commplatform.AssertEquals(expectedMessage)) + } else { + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + } require.NoError(t, err) t.Log("Deleting ConfigMap") @@ -897,7 +1046,12 @@ func runBotTest(t *testing.T, t.Log("Ensuring bot didn't post anything new in second channel...") time.Sleep(appCfg.Slack.MessageWaitTimeout) - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.SecondChannel().ID(), 2, secondCMUpdate) + limitMessages = 2 + if botDriver.Type() == commplatform.SlackBot { + // There are 2 config reload requested after second cm update + limitMessages = 10 + } + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.SecondChannel().ID(), limitMessages, secondCMUpdate) require.NoError(t, err) }) @@ -923,7 +1077,11 @@ func runBotTest(t *testing.T, t.Cleanup(func() { cleanupCreatedPod(t, podDefaultNSCli, podIgnored.Name) }) time.Sleep(appCfg.Slack.MessageWaitTimeout) - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 1, firstCMUpdate) + limitMessages := 1 + if botDriver.Type() == commplatform.SlackBot { + limitMessages = 5 + } + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), limitMessages, firstCMUpdate) require.NoError(t, err) t.Log("Creating Pod...") @@ -987,9 +1145,13 @@ func runBotTest(t *testing.T, } command := fmt.Sprintf(`kubectl get pod -n %s %s`, pod.Namespace, pod.Name) automationAssertionFn := func(content string) (bool, int, string) { + podNameCount := 2 // command + 1 occurrence in the command output + if botDriver.Type() == commplatform.SlackBot { + podNameCount = 3 // command + on cluster name section + 1 occurrence in the command output + } return strings.Contains(content, cmdHeaderWithAuthor(command, "Automation \"Get created resource\"")) && strings.Contains(content, "NAME") && strings.Contains(content, "READY") && strings.Contains(content, "STATUS") && // command output header - strings.Count(content, pod.Name) == 2, // command + 1 occurrence in the command output + strings.Count(content, pod.Name) == podNameCount, 0, "" } err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 2, automationAssertionFn) @@ -1055,10 +1217,10 @@ func runBotTest(t *testing.T, t.Run("List executors", func(t *testing.T) { command := "list executors" expectedBody := codeBlock(heredoc.Doc(` - EXECUTOR ENABLED ALIASES RESTARTS STATUS LAST_RESTART - botkube/echo@v1.0.1-devel true e 0/1 Running - botkube/helm true 0/1 Running - botkube/kubectl true k, kc 0/1 Running`)) + EXECUTOR ENABLED ALIASES RESTARTS STATUS LAST_RESTART + botkube/echo@v0.0.0-latest true e 0/1 Running + botkube/helm true 0/1 Running + botkube/kubectl true k, kc 0/1 Running`)) expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) @@ -1078,6 +1240,9 @@ func runBotTest(t *testing.T, p ping`)) contextMsg := "Only showing aliases for executors enabled for this channel." expectedMessage := fmt.Sprintf("%s\n\n%s\n%s", cmdHeader(command), expectedBody, contextMsg) + if botDriver.Type() == commplatform.SlackBot { + expectedMessage = fmt.Sprintf("%s %s %s", cmdHeader(command), expectedBody, contextMsg) + } botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) err := botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) @@ -1087,102 +1252,15 @@ func runBotTest(t *testing.T, t.Run("List sources", func(t *testing.T) { command := "list sources" expectedBody := codeBlock(heredoc.Doc(` - SOURCE ENABLED RESTARTS STATUS LAST_RESTART`)) - if botDriver.Type() == commplatform.DiscordBot { - expectedBody = codeBlock(heredoc.Doc(` SOURCE ENABLED RESTARTS STATUS LAST_RESTART botkube/cm-watcher true 0/1 Running botkube/kubernetes true 0/1 Running`)) - } - expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) err := botDriver.WaitForLastMessageContains(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) assert.NoError(t, err) }) - t.Run("Plugin crash & recovery", func(t *testing.T) { - t.Run("Crash config map source", func(t *testing.T) { - cfgMapCli := k8sCli.CoreV1().ConfigMaps(appCfg.Deployment.Namespace) - crashConfigMapSourcePlugin(t, cfgMapCli) - - t.Log("Waiting for cm-watcher plugin to recover from panic...") - time.Sleep(appCfg.ShortWaitTimeout) - - cm := &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: testConfigMapName, - }, - } - _, err := cfgMapCli.Create(context.Background(), cm, metav1.CreateOptions{}) - require.NoError(t, err) - - expectedMessage := fmt.Sprintf("Plugin cm-watcher detected `ADDED` event on `%s/%s`", appCfg.Deployment.Namespace, testConfigMapName) - assertionFn := func(msg string) (bool, int, string) { - return strings.Contains(msg, expectedMessage), 0, "" - } - err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 3, assertionFn) - require.NoError(t, err) - - err = cfgMapCli.Delete(context.Background(), testConfigMapName, metav1.DeleteOptions{}) - require.NoError(t, err) - }) - - t.Run("Crash echo executor", func(t *testing.T) { - command := "echo @panic" - expectedMessage := "error reading from server" - - botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - assertionFn := func(msg string) (bool, int, string) { - return strings.Contains(msg, expectedMessage), 0, "" - } - err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) - assert.NoError(t, err) - - t.Log("Waiting for echo plugin to recover from panic...") - time.Sleep(appCfg.ShortWaitTimeout) - - command = "echo hello" - expectedBody := codeBlock(strings.ToUpper(command)) - expectedMessage = fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) - - botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) - assert.NoError(t, err) - - command = "echo @panic" - expectedMessage = "error reading from server" - botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - assertionFn = func(msg string) (bool, int, string) { - return strings.Contains(msg, expectedMessage), 0, "" - } - err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) - assert.NoError(t, err) - - t.Log("Waiting for plugin manager to deactivate echo plugin...") - time.Sleep(appCfg.ShortWaitTimeout) - command = "list executors" - botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - - assertionFn = func(msg string) (bool, int, string) { - return strings.Contains(msg, "Deactivated"), 0, "" - } - err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) - assert.NoError(t, err) - - command = "echo foo" - botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) - t.Log("Ensuring bot didn't post anything new...") - time.Sleep(appCfg.ShortWaitTimeout) - - assertionFn = func(msg string) (bool, int, string) { - return strings.Contains(msg, command), 0, "" - } - err = botDriver.WaitForMessagePosted(botDriver.TesterUserID(), botDriver.Channel().ID(), 1, assertionFn) - assert.NoError(t, err) - }) - }) - t.Run("RBAC", func(t *testing.T) { t.Run("No configuration", func(t *testing.T) { echoParam := "john doe" @@ -1349,6 +1427,87 @@ func runBotTest(t *testing.T, t.Cleanup(func() { cleanupCreatedClusterRoleBinding(t, clusterRoleBindingCli, crb.Name) }) }) }) + + t.Run("Plugin crash & recovery", func(t *testing.T) { + t.Run("Crash config map source", func(t *testing.T) { + cfgMapCli := k8sCli.CoreV1().ConfigMaps(appCfg.Deployment.Namespace) + crashConfigMapSourcePlugin(t, cfgMapCli) + + t.Log("Waiting for cm-watcher plugin to recover from panic...") + time.Sleep(appCfg.ShortWaitTimeout) + + cm := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: testConfigMapName, + }, + } + _, err := cfgMapCli.Create(context.Background(), cm, metav1.CreateOptions{}) + require.NoError(t, err) + + expectedMessage := fmt.Sprintf("Plugin cm-watcher detected `ADDED` event on `%s/%s`", appCfg.Deployment.Namespace, testConfigMapName) + assertionFn := func(msg string) (bool, int, string) { + return strings.Contains(msg, expectedMessage), 0, "" + } + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 3, assertionFn) + require.NoError(t, err) + + err = cfgMapCli.Delete(context.Background(), testConfigMapName, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("Crash echo executor", func(t *testing.T) { + command := "echo @panic" + expectedMessage := "error reading from server" + + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + assertionFn := func(msg string) (bool, int, string) { + return strings.Contains(msg, expectedMessage), 0, "" + } + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + + t.Log("Waiting for echo plugin to recover from panic...") + time.Sleep(appCfg.ShortWaitTimeout) + + command = "echo hello" + expectedBody := codeBlock(strings.ToUpper(command)) + expectedMessage = fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) + + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + assert.NoError(t, err) + + command = "echo @panic" + expectedMessage = "error reading from server" + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + assertionFn = func(msg string) (bool, int, string) { + return strings.Contains(msg, expectedMessage), 0, "" + } + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + + t.Log("Waiting for plugin manager to deactivate echo plugin...") + time.Sleep(appCfg.ShortWaitTimeout) + command = "list executors" + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + + assertionFn = func(msg string) (bool, int, string) { + return strings.Contains(msg, "Deactivated"), 0, "" + } + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + + command = "echo foo" + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + t.Log("Ensuring bot didn't post anything new...") + + assertionFn = func(msg string) (bool, int, string) { + return strings.Contains(msg, command), 0, "" + } + err = botDriver.WaitForMessagePosted(botDriver.TesterUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + }) + }) } type aliasedCmd struct { @@ -1450,3 +1609,15 @@ func crashConfigMapSourcePlugin(t *testing.T, cfgMapCli corev1.ConfigMapInterfac err = cfgMapCli.Delete(context.Background(), testConfigMapName, metav1.DeleteOptions{}) require.NoError(t, err) } + +func waitForRestart(t *testing.T, tester commplatform.BotDriver, userID, channel, clusterName string) { + t.Log("Waiting for restart...") + originalTimeout := tester.Timeout() + tester.SetTimeout(90 * time.Second) + // 2, since time to time latest message becomes upgrade message right after begin message + err := tester.WaitForMessagePosted(userID, channel, 2, func(content string) (bool, int, string) { + return content == fmt.Sprintf("My watch begins for cluster '%s'! :crossed_swords:", clusterName), 0, "" + }) + tester.SetTimeout(originalTimeout) + require.NoError(t, err) +} diff --git a/test/e2e/gql_client.go b/test/e2e/gql_client.go new file mode 100644 index 000000000..9c26e167a --- /dev/null +++ b/test/e2e/gql_client.go @@ -0,0 +1,500 @@ +package e2e + +import ( + "context" + "net/http" + "testing" + + "github.com/hasura/go-graphql-client" + "github.com/stretchr/testify/require" + + "github.com/kubeshop/botkube/internal/ptr" + gqlModel "github.com/kubeshop/botkube/internal/remote/graphql" +) + +const ( + //nolint:gosec // G101: Potential hardcoded credentials + botkubeAPIKeyHeaderName = "X-API-Key" +) + +// Client provides helper functions for queries and mutations that across different test cases. +// It simplifies setting up a given test prerequisites that are not a part of the test itself. +type Client struct { + *graphql.Client +} + +// CreateBasicDeploymentWithCloudSlack create deployment with Slack platform and three plugins. +func (c *Client) CreateBasicDeploymentWithCloudSlack(t *testing.T, clusterName, slackTeamID, firstChannel, secondChannel, thirdChannel string) (*gqlModel.Deployment, error) { + t.Helper() + + var mutation struct { + CreateDeployment *gqlModel.Deployment `graphql:"createDeployment(input: $input)"` + } + + rbac := gqlModel.RBACInput{ + User: &gqlModel.UserPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.UserStaticSubjectInput{Value: "botkube-plugins-default"}, + }, + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{"botkube-plugins-default"}}, + }, + } + + err := c.Client.Mutate(context.Background(), &mutation, map[string]interface{}{ + "input": gqlModel.DeploymentCreateInput{ + Name: clusterName, + Plugins: []*gqlModel.PluginsCreateInput{ + { + Groups: []*gqlModel.PluginConfigurationGroupInput{ + { + Name: "botkube/kubernetes", + DisplayName: "K8s recommendations", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-events", + Configuration: "{\"log\":{\"level\":\"debug\"},\"recommendations\":{\"pod\":{\"noLatestImageTag\":true,\"labelsSet\":true},\"ingress\":{\"backendServiceValid\":false,\"tlsSecretValid\":false}},\"namespaces\":{\"include\":[\"botkube\"]},\"event\":{\"types\":[\"create\",\"update\"]},\"resources\":[{\"type\":\"v1/configmaps\",\"updateSetting\":{\"includeDiff\":false,\"fields\":[\"data\"]}}]}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "K8s ConfigMap delete events", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-annotated-cm-delete", + Configuration: "{\"log\":{\"level\":\"debug\"},\"namespaces\":{\"include\":[\"botkube\"]},\"labels\":{\"test.botkube.io\":\"true\"},\"event\":{\"types\":[\"delete\"]},\"resources\":[{\"type\":\"v1/configmaps\"}]}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "Pod Create Events", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-pod-create-events", + Configuration: "{\"log\":{\"level\":\"debug\"},\"namespaces\":{\"include\":[\"botkube\"]},\"event\":{\"types\":[\"create\"]},\"resources\":[{\"type\":\"v1/pods\"}]}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "K8s Service creation, used only by action", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-service-create-event-for-action-only", + Configuration: "{\"namespaces\":{\"include\":[\"botkube\"]},\"event\":{\"types\":[\"create\"]},\"resources\":[{\"type\":\"v1/services\"}]}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "K8s ConfigMaps updates", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-updates", + Configuration: "{\"log\":{\"level\":\"debug\"},\"namespaces\":{\"include\":[\"default\"]},\"event\":{\"types\":[\"create\",\"update\",\"delete\"]},\"resources\":[{\"type\":\"v1/configmaps\",\"namespaces\":{\"include\":[\"botkube\"]},\"event\":{\"types\":[\"update\"]},\"updateSetting\":{\"includeDiff\":false,\"fields\":[\"data\"]}}]}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "K8s ConfigMaps updates", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "rbac-with-static-mapping", + Configuration: "{\"namespaces\":{\"include\":[\"botkube\"]},\"annotations\":{\"rbac.botkube.io\":\"true\"},\"event\":{\"types\":[\"create\"]},\"resources\":[{\"type\":\"v1/configmaps\"}]}", + Rbac: &gqlModel.RBACInput{ + User: &gqlModel.UserPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.UserStaticSubjectInput{Value: "kc-watch-cm"}, + Prefix: ptr.FromType[string](""), + }, + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{"kc-watch-cm"}}, + Prefix: ptr.FromType[string](""), + }, + }, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Resource Created Events", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-create-events", + Configuration: "{\"namespaces\":{\"include\":[\"*\"],\"event\":{\"types\":[\"create\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"v1/services\"},{\"type\":\"networking.k8s.io/v1/ingresses\"},{\"type\":\"v1/nodes\"},{\"type\":\"v1/namespaces\"},{\"type\":\"v1/configmaps\"},{\"type\":\"apps/v1/deployments\"},{\"type\":\"apps/v1/statefulsets\"},{\"type\":\"apps/v1/daemonsets\"},{\"type\":\"batch/v1/jobs\"}]}}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Errors for resources with logs", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-err-with-logs-events", + Configuration: "{\"namespaces\":{\"include\":[\"*\"],\"event\":{\"types\":[\"error\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"apps/v1/deployments\"},{\"type\":\"apps/v1/statefulsets\"},{\"type\":\"apps/v1/daemonsets\"},{\"type\":\"batch/v1/jobs\"}]}}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/cm-watcher", + DisplayName: "K8s ConfigMaps changes", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "other-plugins", + Configuration: "{\"configMap\":{\"name\":\"cm-watcher-trigger\",\"namespace\":\"botkube\",\"event\":\"ADDED\"}}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/cm-watcher", + DisplayName: "CM watcher RBAC", + Type: gqlModel.PluginTypeSource, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "rbac-with-default-configuration", + Configuration: "{\"configMap\":{\"name\":\"cm-rbac\",\"namespace\":\"botkube\",\"event\":\"DELETED\"}}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/kubectl", + DisplayName: "Default Tools", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "k8s-default-tools", + Configuration: "{}", + }, + }, + }, + { + Name: "botkube/kubectl", + DisplayName: "First channel", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "kubectl-first-channel-cmd", + Configuration: "{}", + Rbac: &gqlModel.RBACInput{ + User: &gqlModel.UserPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.UserStaticSubjectInput{Value: "kubectl-first-channel"}, + }, + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{}}, + }}, + }, + }, + }, + { + Name: "botkube/kubectl", + DisplayName: "Not bounded", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "kubectl-not-bound-to-any-channel", + Configuration: "{}", + Rbac: &gqlModel.RBACInput{ + User: &gqlModel.UserPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.UserStaticSubjectInput{Value: "kubectl-first-channel"}, + }, + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{}}, + }, + }, + }, + }, + }, + { + Name: "botkube/kubectl", + DisplayName: "Service label perms", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "kubectl-with-svc-label-perms", + Configuration: "{}", + Rbac: &gqlModel.RBACInput{ + User: &gqlModel.UserPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.UserStaticSubjectInput{Value: "kc-label-svc-all"}, + }, + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeStatic, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{}}, + }, + }, + }, + }, + }, + { + Name: "botkube/kubectl", + DisplayName: "Rbac Channel Mapping", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "rbac-with-channel-mapping", + Configuration: "{\"defaultNamespace\":\"botkube\"}", + Rbac: &gqlModel.RBACInput{ + Group: &gqlModel.GroupPolicySubjectInput{ + Type: gqlModel.PolicySubjectTypeChannelName, + Static: &gqlModel.GroupStaticSubjectInput{Values: []string{""}}, + }, + }, + }, + }, + }, + { + Name: "botkube/helm", + DisplayName: "Helm", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "helm", + Configuration: "{}", + Rbac: &rbac, + }, + }, + }, + { + Name: "botkube/echo@v0.0.0-latest", + DisplayName: "Echo", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "other-plugins", + Configuration: "{\"changeResponseToUpperCase\":true}", + }, + }, + }, + { + Name: "botkube/echo@v0.0.0-latest", + DisplayName: "Echo with no RBAC", + Type: gqlModel.PluginTypeExecutor, + Configurations: []*gqlModel.PluginConfigurationInput{ + { + Name: "rbac-with-no-configuration", + Configuration: "{\"changeResponseToUpperCase\":true}", + }, + }, + }, + }, + }, + }, + Actions: []*gqlModel.ActionCreateUpdateInput{ + { + Name: "get-created-resource", + DisplayName: "Get created resource", + Enabled: true, + Command: "kubectl get {{ .Event.Kind | lower }}{{ if .Event.Namespace }} -n {{ .Event.Namespace }}{{ end }} {{ .Event.Name }}", + Bindings: &gqlModel.ActionCreateUpdateInputBindings{ + Sources: []string{"k8s-pod-create-events"}, + Executors: []string{"k8s-default-tools"}, + }, + }, + { + Name: "label-created-svc-resource", + DisplayName: "Label created Service", + Enabled: true, + Command: "kubectl label svc {{ if .Event.Namespace }} -n {{ .Event.Namespace }}{{ end }} {{ .Event.Name }} botkube-action=true", + Bindings: &gqlModel.ActionCreateUpdateInputBindings{ + Sources: []string{"k8s-service-create-event-for-action-only"}, + Executors: []string{"kubectl-with-svc-label-perms"}, + }, + }, + { + Name: "describe-created-resource", + DisplayName: "Describe created resource", + Enabled: false, + Command: "kubectl describe {{ .Event.Kind | lower }}{{ if .Event.Namespace }} -n {{ .Event.Namespace }}{{ end }} {{ .Event.Name }}", + Bindings: &gqlModel.ActionCreateUpdateInputBindings{ + Sources: []string{"k8s-create-events"}, + Executors: []string{"k8s-default-tools"}, + }, + }, + { + Name: "show-logs-on-error", + DisplayName: "Show logs on error", + Enabled: false, + Command: "kubectl logs {{ .Event.Kind | lower }}/{{ .Event.Name }} -n {{ .Event.Namespace }}", + Bindings: &gqlModel.ActionCreateUpdateInputBindings{ + Sources: []string{"k8s-err-with-logs-events"}, + Executors: []string{"k8s-default-tools"}, + }, + }, + }, + Platforms: &gqlModel.PlatformsCreateInput{ + CloudSlacks: []*gqlModel.CloudSlackCreateInput{ + { + Name: "Cloud Slack", + TeamID: slackTeamID, + Channels: []*gqlModel.ChannelBindingsByNameCreateInput{ + { + Name: firstChannel, + Bindings: &gqlModel.BotBindingsCreateInput{ + Sources: []*string{ptr.FromType("k8s-events"), ptr.FromType("k8s-annotated-cm-delete"), ptr.FromType("k8s-pod-create-events"), ptr.FromType("other-plugins")}, + Executors: []*string{ptr.FromType("kubectl-first-channel-cmd"), ptr.FromType("other-plugins"), ptr.FromType("helm")}, + }, + NotificationsDisabled: ptr.FromType[bool](false), + }, + { + Name: secondChannel, + Bindings: &gqlModel.BotBindingsCreateInput{ + Sources: []*string{ptr.FromType("k8s-updates")}, + Executors: []*string{ptr.FromType("k8s-default-tools")}, + }, + NotificationsDisabled: ptr.FromType[bool](true), + }, + { + Name: thirdChannel, + Bindings: &gqlModel.BotBindingsCreateInput{ + Sources: []*string{ptr.FromType("rbac-with-static-mapping"), ptr.FromType("rbac-with-default-configuration")}, + Executors: []*string{ptr.FromType("rbac-with-channel-mapping"), ptr.FromType("rbac-with-no-configuration")}, + }, + NotificationsDisabled: ptr.FromType[bool](false), + }, + }, + }, + }, + }, + AttachDefaultAliases: ptr.FromType[bool](true), + }, + }) + return mutation.CreateDeployment, err +} + +// MustCreateBasicDeploymentWithCloudSlack is like CreateBasicDeploymentWithCloudSlack but fails on error. +func (c *Client) MustCreateBasicDeploymentWithCloudSlack(t *testing.T, clusterName, slackTeamID, firstChannel, secondChannel, thirdChannel string) *gqlModel.Deployment { + t.Helper() + deployment, err := c.CreateBasicDeploymentWithCloudSlack(t, clusterName, slackTeamID, firstChannel, secondChannel, thirdChannel) + require.NoError(t, err) + return deployment +} + +type ( + // Organization is a custom model that allow us to skip the 'connectedPlatforms.slack' field. Otherwise, we get such error: + // + // Field "slack" argument "id" of type "ID!" is required, but it was not provided. + Organization struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Subscription *gqlModel.OrganizationSubscription `json:"subscription"` + ConnectedPlatforms *OrganizationConnectedPlatforms `json:"connectedPlatforms"` + OwnerID string `json:"ownerId"` + Owner *gqlModel.User `json:"owner"` + Members []*gqlModel.User `json:"members"` + Quota *gqlModel.Quota `json:"quota"` + BillingHistoryAvailable bool `json:"billingHistoryAvailable"` + UpdateOperations *gqlModel.OrganizationUpdateOperations `json:"updateOperations"` + Usage *gqlModel.Usage `json:"usage"` + } + // Organizations holds organization collection. + Organizations []Organization + + // OrganizationConnectedPlatforms skips the 'slack' field. + OrganizationConnectedPlatforms struct { + Slacks []*gqlModel.SlackWorkspace `json:"slacks"` + } +) + +// ToModel returns official gql model. +func (o Organization) ToModel() gqlModel.Organization { + return gqlModel.Organization{ + ID: o.ID, + DisplayName: o.DisplayName, + Subscription: o.Subscription, + ConnectedPlatforms: &gqlModel.OrganizationConnectedPlatforms{ + Slacks: o.ConnectedPlatforms.Slacks, + }, + OwnerID: o.OwnerID, + Owner: o.Owner, + Members: o.Members, + Quota: o.Quota, + BillingHistoryAvailable: o.BillingHistoryAvailable, + UpdateOperations: o.UpdateOperations, + Usage: o.Usage, + } +} + +// ToModel returns official gql model. +func (o Organizations) ToModel() []gqlModel.Organization { + var out []gqlModel.Organization + for _, item := range o { + out = append(out, item.ToModel()) + } + return out +} + +// MustDeleteDeployment is like DeleteDeployment but panics on error. +func (c *Client) MustDeleteDeployment(t *testing.T, id graphql.ID) { + err := c.DeleteDeployment(t, id) + require.NoError(t, err) +} + +// DeleteDeployment deletes a given deployment scoped to a given user. +func (c *Client) DeleteDeployment(t *testing.T, id graphql.ID) error { + t.Helper() + + var mutation struct { + Deployment bool `graphql:"deleteDeployment(id: $id)"` + } + + return c.Client.Mutate(context.Background(), &mutation, map[string]interface{}{"id": id}) +} + +// MustCreateAlias creates alias. +func (c *Client) MustCreateAlias(t *testing.T, name, displayName, command, deploymentId string) gqlModel.Alias { + t.Helper() + + var mutation struct { + CreateAlias gqlModel.Alias `graphql:"createAlias(input: $input)"` + } + + err := c.Client.Mutate(context.Background(), &mutation, map[string]interface{}{ + "input": gqlModel.AliasCreateInput{ + Name: name, + DisplayName: displayName, + Command: command, + DeploymentIds: []string{deploymentId}, + }, + }) + require.NoError(t, err) + + return mutation.CreateAlias +} + +// NewClientForAPIKey returns new GraphQL client with API Key header. +func NewClientForAPIKey(apiEndpoint, key string) *Client { + gqLCli := graphql.NewClient(apiEndpoint, nil) + + return &Client{ + Client: gqLCli.WithRequestModifier(func(request *http.Request) { + request.Header.Set(botkubeAPIKeyHeaderName, key) + }), + } +} diff --git a/test/e2e/slack_helpers_test.go b/test/e2e/slack_helpers_test.go index ae75feeac..e9747f8e2 100644 --- a/test/e2e/slack_helpers_test.go +++ b/test/e2e/slack_helpers_test.go @@ -11,7 +11,7 @@ import ( var slackLinks = regexp.MustCompile(`<(?Phttps://[^>]*)>`) -func removeSlackLinksIndicators(content string) string { +func RemoveSlackLinksIndicators(content string) string { tpl := "$val" return slackLinks.ReplaceAllStringFunc(content, func(s string) string { @@ -47,7 +47,7 @@ func TestRemoveSlackLinksIndicators(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // when - got := removeSlackLinksIndicators(tc.content) + got := RemoveSlackLinksIndicators(tc.content) // then assert.Equal(t, tc.expected, got)