Newer
Older
(ns metabase.integrations.slack-test
(:require [cheshire.core :as json]
[clj-http.fake :as http-fake]
[clojure.java.io :as io]
[clojure.test :refer :all]
[medley.core :as m]
[metabase.email.messages :as messages]
[metabase.integrations.slack :as slack]
[metabase.test :as mt]
[metabase.test.util :as tu]
[schema.core :as s])
(:import java.nio.charset.Charset
org.apache.commons.io.IOUtils
org.apache.http.client.utils.URLEncodedUtils
org.apache.http.NameValuePair))
(defn- parse-query-string [query-string]
(into {} (for [^NameValuePair pair (URLEncodedUtils/parse (str query-string) (Charset/forName "UTF-8"))]
[(keyword (.getName pair)) (.getValue pair)])))
(defn- mock-paged-response-body [{:keys [query-string], :as request} response-body]
(if (string? response-body)
(recur request (json/parse-string response-body true))
(let [{:keys [cursor]} (parse-query-string query-string)]
;; if the mock handler is called without a `cursor` param, return response with a `next_cursor`; if passed that
;; `cursor`, remove the `next_cursor`. That way we should get two pages total for testing paging
(if (seq cursor)
(m/dissoc-in response-body [:response_metadata :next_cursor])
response-body))))
(defn- mock-conversations-response-body [request]
(mock-paged-response-body request (slurp "./test_resources/slack_channels_response.json")))
(defn- mock-conversations []
(:channels (mock-conversations-response-body nil)))
(def ^:private conversations-endpoint #"^https://slack\.com/api/conversations\.list.*")
(defn- mock-200-response [body]
{:status 200
:body (if (string? body)
(json/generate-string body))})
(defn- test-no-auth-token
"Test that a Slack API endpoint function returns `nil` if a Slack API token isn't configured."
[endpoint thunk]
(http-fake/with-fake-routes {endpoint (fn [_]
(throw (Exception. "Failure, route should not have been invoked")))}
(testing "should return nil if no Slack token has been configured"
(tu/with-temporary-setting-values [slack-app-token nil
slack-token nil]
(not-empty (thunk))))))))
(defn- test-invalid-auth-token
"Test that a Slack API endpoint function throws an Exception if an invalid Slack API token is set."
[endpoint thunk]
(testing "should throw Exception if auth token is invalid"
(http-fake/with-fake-routes {endpoint (constantly
(mock-200-response {:ok false
:error "invalid_auth"}))}
(tu/with-temporary-setting-values [slack-app-token "test-token"]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Invalid token"
(thunk)))
(try
(thunk)
(catch clojure.lang.ExceptionInfo e
(is (= {:slack-token "Invalid token"}
(:errors (ex-data e))))))))))
(defn- test-auth
"Test that a Slack API `endpoint` function works as expected when Slack token is missing or invalid."
[endpoint thunk]
(doseq [f [test-no-auth-token test-invalid-auth-token]]
(f endpoint thunk)))
(deftest conversations-list-test
(testing "conversations-list"
(test-auth conversations-endpoint slack/conversations-list)
(testing "should be able to fetch channels and paginate"
(http-fake/with-fake-routes {conversations-endpoint (comp mock-200-response mock-conversations-response-body)}
(let [expected-result (map
(comp #(str \# %) :name)
(concat (mock-conversations) (mock-conversations)))]
(tu/with-temporary-setting-values [slack-token "test-token"
slack-app-token nil]
(is (= expected-result
(slack/conversations-list))))
(tu/with-temporary-setting-values [slack-app-token "test-token"
slack-token nil]
(is (= expected-result
(slack/conversations-list)))))))))
(deftest valid-token?-test
(testing "valid-token?"
;; should ignore the values of `slack-token` and `slack-app-token` settings
(doseq [setting-value ["test-token" nil]]
(tu/with-temporary-setting-values [slack-token setting-value
slack-app-token setting-value]
(http-fake/with-fake-routes {conversations-endpoint (fn [{:keys [headers], :as request}]
(is (= "Bearer\nabc"
(get headers "Authorization")))
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
(mock-200-response (mock-conversations-response-body request)))}
(is (= true
(slack/valid-token? "abc"))))
(testing "invalid token should return false"
(http-fake/with-fake-routes {conversations-endpoint (constantly
(mock-200-response {:ok false
:error "invalid_auth"}))}
(is (= false
(slack/valid-token? "abc")))))
(testing "other error should be thrown as an Exception"
(http-fake/with-fake-routes {conversations-endpoint (constantly
(mock-200-response {:ok false
:error "some_other_error"}))}
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Slack API error: some_other_error"
(slack/valid-token? "abc")))))))))
(defn- mock-users-response-body [request]
(mock-paged-response-body request (slurp "./test_resources/slack_users_response.json")))
(defn- mock-users []
(:members (mock-users-response-body nil)))
(def ^:private users-endpoint #"^https://slack\.com/api/users\.list.*")
(deftest users-list-test
(testing "users-list"
(test-auth users-endpoint slack/users-list)
(testing "should be able to fetch list of users and page"
(http-fake/with-fake-routes {users-endpoint (comp mock-200-response mock-users-response-body)}
(let [expected-result (map
(comp #(str \@ %) :name)
(concat (mock-users) (mock-users)))]
(tu/with-temporary-setting-values [slack-token nil
slack-app-token "test-token"]
(is (= expected-result
(slack/users-list)))
(tu/with-temporary-setting-values [slack-app-token nil
slack-token "test-token"]
(is (= expected-result
(slack/users-list))))))))))
(deftest files-channel-test
(testing "files-channel"
(testing "Should be able to get the files-channel from the cache (if it exists)"
(tu/with-temporary-setting-values [slack-files-channel "general"
slack-cached-channels-and-usernames ["#general" "#random" "#off-topic" "#cooking" "@john" "@james" "@jordan"]]
(is (= "general" (slack/files-channel))))
(tu/with-temporary-setting-values [slack-files-channel "not_in_the_cache"
slack-cached-channels-and-usernames ["#general"]]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Slack channel named.*is missing.*"
(slack/files-channel)))))))
(deftest upload-file!-test
(testing "upload-file!"
(let [image-bytes (with-open [is (io/input-stream (io/resource "frontend_client/favicon.ico"))]
(IOUtils/toByteArray is))
filename "wow.gif"
channel-id "C13372B6X"]
(http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*"
(fn [_] (mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))}
(tu/with-temporary-setting-values [slack-token "test-token"
slack-app-token nil]
(is (= "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif"
(slack/upload-file! image-bytes filename channel-id)))))
;; Slack app token requires joining the `metabase_files` channel before uploading a file
(http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*"
(fn [_] (mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))
#"^https://slack.com/api/conversations\.join.*"
(fn [_] (mock-200-response (slurp "./test_resources/slack_conversations_join_response.json")))}
(tu/with-temporary-setting-values [slack-token nil
slack-app-token "test-token"]
(is (= "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif"
(slack/upload-file! image-bytes filename channel-id))))))))
(deftest post-chat-message!-test
(testing "post-chat-message!"
(http-fake/with-fake-routes {#"^https://slack.com/api/chat\.postMessage.*" (fn [_]
(mock-200-response (slurp "./test_resources/slack_post_chat_message_response.json")))}
(let [expected-schema {:ok (s/eq true)
:message {:type (s/eq "message")
:subtype (s/eq "bot_message")
:text (s/eq ":wow:")
s/Keyword s/Any}
s/Keyword s/Any}]
(tu/with-temporary-setting-values [slack-token "test-token"
slack-app-token nil]
(is (schema= expected-schema
(slack/post-chat-message! "C94712B6X" ":wow:"))))
(tu/with-temporary-setting-values [slack-app-token "test-token"
slack-token nil]
(is (schema= expected-schema
(slack/post-chat-message! "C94712B6X" ":wow:"))))))))
(deftest slack-token-error-test
(with-redefs [messages/all-admin-recipients (constantly ["crowberto@metabase.com"])]
(tu/with-temporary-setting-values [slack-app-token "test-token"
#_:clj-kondo/ignore slack-token-valid? true]
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
(mt/with-fake-inbox
(http-fake/with-fake-routes {#"^https://slack.com/api/chat\.postMessage.*"
(fn [_] (mock-200-response {:ok false, :error "account_inactive"}))}
(testing "If a slack token is revoked, an email should be sent to admins, and the `slack-token-valid?` setting
should be set to false"
(try
(slack/post-chat-message! "C94712B6X" ":wow:")
(catch Throwable e
(is (= "Invalid token" (ex-message e)))
(is (= (mt/email-to :crowberto {:subject "Your Slack connection stopped working"
:to #{"crowberto@metabase.com"}
:body [{"Your Slack connection stopped working." true}]})
(mt/summarize-multipart-email #"Your Slack connection stopped working.")))
(is (false? (slack/slack-token-valid?))))))
(testing "If `slack-token-valid?` is already false, no email should be sent"
(mt/reset-inbox!)
(try
(slack/post-chat-message! "C94712B6X" ":wow:")
(catch Throwable e
(is (= "Invalid token" (ex-message e)))
(is (= {} (mt/summarize-multipart-email #"Your Slack connection stopped working.")))))))
(testing "No email is sent during token validation checks, even if `slack-token-valid?` is currently true"
(tu/with-temporary-setting-values [slack-token-valid? true]
(http-fake/with-fake-routes {conversations-endpoint (fn [_] (mock-200-response {:ok false, :error "account_inactive"}))}
(mt/reset-inbox!)
(is (= false (slack/valid-token? "abc")))
(is (= {} (mt/summarize-multipart-email #"Your Slack connection stopped working.")))
(is (slack/slack-token-valid?)))))))))
(deftest slack-cache-updated-at-nil
(tu/with-temporary-setting-values [slack-channels-and-usernames-last-updated nil]
(is (= (var-get #'slack/zoned-time-epoch) (slack/slack-channels-and-usernames-last-updated)))))