diff --git a/project.clj b/project.clj index 9374b0a266f78d47d0437783029287aa8dea36fe..ccb9a195b8b847b3c42f1968e034ead3942eb1e2 100644 --- a/project.clj +++ b/project.clj @@ -140,7 +140,8 @@ :docstring-checker {:include [#"^metabase"] :exclude [#"test" #"^metabase\.http-client$"]} - :profiles {:dev {:dependencies [[expectations "2.2.0-beta2"] ; unit tests + :profiles {:dev {:dependencies [[clj-http-fake "1.0.3"] ; Library to mock clj-http responses + [expectations "2.2.0-beta2"] ; unit tests [ring/ring-mock "0.3.0"]] ; Library to create mock Ring requests for unit tests :plugins [[docstring-checker "1.0.2"] ; Check that all public vars have docstrings. Run with 'lein docstring-checker' [jonase/eastwood "0.3.1" diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index 97df78f092a442ec7b71a0ec5bdb8d8b0459303c..e0164cdf71e4fdbf7a070a8da6bb592a3f6a6e65 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -1,6 +1,7 @@ (ns metabase.integrations.slack (:require [cheshire.core :as json] [clj-http.client :as http] + [clojure.java.io :as io] [clojure.tools.logging :as log] [metabase.models.setting :as setting :refer [defsetting]] [metabase.util.i18n :refer [tru]] @@ -19,7 +20,7 @@ (defn- handle-response [{:keys [status body]}] - (let [body (json/parse-string body keyword)] + (let [body (-> body io/reader (json/parse-stream keyword))] (if (and (= 200 status) (:ok body)) body (let [error (if (= (:error body) "invalid_auth") @@ -31,15 +32,16 @@ (defn- do-slack-request [request-fn params-key endpoint & {:keys [token], :as params, :or {token (slack-token)}}] (when token (handle-response (request-fn (str slack-api-base-url "/" (name endpoint)) {params-key (assoc params :token token) - :conn-timeout 1000 - :socket-timeout 1000})))) + :as :stream + :conn-timeout 1000 + :socket-timeout 1000})))) (def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} GET "Make a GET request to the Slack API." (partial do-slack-request http/get :query-params)) (def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} POST "Make a POST request to the Slack API." (partial do-slack-request http/post :form-params)) (def ^{:arglists '([& {:as args}])} channels-list "Calls Slack api `channels.list` function and returns the list of available channels." - (comp :channels (partial GET :channels.list, :exclude_archived 1))) + (comp :channels (partial GET :channels.list, :exclude_archived true, :exclude_members true))) (def ^{:arglists '([& {:as args}])} users-list "Calls Slack api `users.list` function and returns the list of available users." @@ -55,7 +57,7 @@ [] (some (fn [channel] (when (= (:name channel) files-channel-name) channel)) - (channels-list :exclude_archived 0))) + (channels-list :exclude_archived false))) (defn files-channel "Calls Slack api `channels.info` to check whether a channel named #metabase_files exists. If it doesn't, diff --git a/test/metabase/integrations/slack_test.clj b/test/metabase/integrations/slack_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..21f80a3265fdab441b43aba18c6bb396703928f5 --- /dev/null +++ b/test/metabase/integrations/slack_test.clj @@ -0,0 +1,122 @@ +(ns metabase.integrations.slack-test + (:require [cheshire.core :as json] + [clj-http.fake :as http-fake] + [clojure.java.io :as io] + [expectations :refer :all] + [metabase.integrations.slack :as slack-integ :refer :all] + [metabase.test.util :as tu])) + +(def ^:private default-channels-response + (delay (slurp (io/resource "slack_channels_response.json")))) + +(def ^:private default-channels + (delay (:channels (json/parse-string @default-channels-response keyword)))) + +(def ^:private channels-request + {:address "https://slack.com/api/channels.list" + :query-params {:token "test-token" + :exclude_archived "true" + :exclude_members "true"}}) + +(defn- expected-200-response [body] + (fn [_] + {:status 200 + :body (if (string? body) + body + (json/generate-string body))})) + +(def ^:private invalid-token-response + (expected-200-response + {:ok false + :error "invalid_auth"})) + +(defn- exception-if-called [_] + (throw (Exception. "Failure, route should not have been invoked"))) + +;; Channels should return nil if no Slack token has been configured +(expect + nil + (http-fake/with-fake-routes {channels-request exception-if-called} + (tu/with-temporary-setting-values [slack-token nil] + (channels-list)))) + +;; Test the channels call and expected response +(expect + @default-channels + (http-fake/with-fake-routes {channels-request (expected-200-response @default-channels-response)} + (tu/with-temporary-setting-values [slack-token "test-token"] + (channels-list)))) + +;; Test the invalid token auth flow +(expect + {:ex-class clojure.lang.ExceptionInfo + :msg nil + :data {:errors {:slack-token "Invalid token"}}} + (http-fake/with-fake-routes {channels-request invalid-token-response} + (tu/with-temporary-setting-values [slack-token "test-token"] + (tu/exception-and-message + (channels-list))))) + +(def ^:private default-users-response + (delay (slurp (io/resource "slack_users_response.json")))) + +(def ^:private default-users + (delay (:members (json/parse-string @default-users-response keyword)))) + +(def ^:private users-request + {:address "https://slack.com/api/users.list" + :query-params {:token "test-token"}}) + +;; Users should return nil if no Slack token has been configured +(expect + nil + (http-fake/with-fake-routes {users-request exception-if-called} + (tu/with-temporary-setting-values [slack-token nil] + (users-list)))) + +;; Test the users call and the expected response +(expect + @default-users + (http-fake/with-fake-routes {users-request (expected-200-response @default-users-response)} + (tu/with-temporary-setting-values [slack-token "test-token"] + (users-list)))) + +;; Test the invalid token auth flow for users +(expect + {:ex-class clojure.lang.ExceptionInfo + :msg nil + :data {:errors {:slack-token "Invalid token"}}} + (http-fake/with-fake-routes {users-request invalid-token-response} + (tu/with-temporary-setting-values [slack-token "test-token"] + (tu/exception-and-message + (users-list))))) + +(def ^:private files-request + (assoc-in channels-request [:query-params :exclude_archived] "false")) + +;; Asking for the files channel when slack is not configured throws an exception +(expect + {:ex-class clojure.lang.ExceptionInfo + :msg (var-get #'slack-integ/channel-missing-msg) + :data {:status-code 400}} + (http-fake/with-fake-routes {files-request exception-if-called} + (tu/exception-and-message + (files-channel)))) + +(defn- create-files-channel [] + (let [channel-name (var-get #'slack-integ/files-channel-name)] + (-> @default-channels + first + (assoc + :name channel-name, :name_normalized channel-name, + :purpose {:value "Metabase file upload location", :creator "", :last_set 0})))) + +;; Testing the call that finds the metabase files channel +(expect + (create-files-channel) + (http-fake/with-fake-routes {files-request (-> @default-channels-response + json/parse-string + (update :channels conj (create-files-channel)) + expected-200-response)} + (tu/with-temporary-setting-values [slack-token "test-token"] + (files-channel)))) diff --git a/test/metabase/query_processor/middleware/add_query_throttle_test.clj b/test/metabase/query_processor/middleware/add_query_throttle_test.clj index 9abe9798be2591bbad8a292c7502ba0d210a4969..9387c696684a55b9da2377a7a79bc485c81c7418 100644 --- a/test/metabase/query_processor/middleware/add_query_throttle_test.clj +++ b/test/metabase/query_processor/middleware/add_query_throttle_test.clj @@ -10,14 +10,6 @@ [metabase.util :as u]) (:import java.util.concurrent.Semaphore)) -(defmacro ^:private exception-and-message [& body] - `(try - ~@body - (catch Exception e# - {:ex-class (class e#) - :msg (.getMessage e#) - :data (ex-data e#)}))) - (defmacro ^:private with-query-wait-time-in-seconds [time-in-seconds & body] `(with-redefs [throttle/max-query-wait-time-in-millis ~(* 1000 time-in-seconds)] ~@body)) @@ -30,7 +22,7 @@ :data {:status-code 503 :type ::throttle/concurrent-query-limit-reached}} (with-query-wait-time-in-seconds 1 - (exception-and-message + (tu/exception-and-message (let [semaphore (Semaphore. 5)] (.acquire semaphore 5) ((#'throttle/throttle-queries semaphore (constantly "Should never be returned")) {}))))) @@ -43,7 +35,7 @@ :data {:status-code 503 :type ::throttle/concurrent-query-limit-reached}} (with-query-wait-time-in-seconds 1 - (exception-and-message + (tu/exception-and-message (let [semaphore (Semaphore. 5) my-qp (->> identity (#'throttle/throttle-queries semaphore) diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index 705fe1a975a5e206d0be6abab7124e327077a22d..ad4dc0e724d7d86094a623e2961e1186c6bc4eda 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -675,3 +675,13 @@ :else x)) + +(defmacro exception-and-message + "Invokes `body`, catches the exception and returns a map with the exception class, message and data" + [& body] + `(try + ~@body + (catch Exception e# + {:ex-class (class e#) + :msg (.getMessage e#) + :data (ex-data e#)}))) diff --git a/test_resources/slack_channels_response.json b/test_resources/slack_channels_response.json new file mode 100644 index 0000000000000000000000000000000000000000..b38aa245adecb5410be5fc0b30c708401411825b --- /dev/null +++ b/test_resources/slack_channels_response.json @@ -0,0 +1,70 @@ +{ + "ok": true, + "channels": [ + { + "id": "C0G9QF9GW", + "name": "random", + "is_channel": true, + "created": 1449709280, + "creator": "U0G9QF9C6", + "is_archived": false, + "is_general": false, + "name_normalized": "random", + "is_shared": false, + "is_org_shared": false, + "is_member": true, + "is_private": false, + "is_mpim": false, + "members": [ + "U0G9QF9C6", + "U0G9WFXNZ" + ], + "topic": { + "value": "Other stuff", + "creator": "U0G9QF9C6", + "last_set": 1449709352 + }, + "purpose": { + "value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.", + "creator": "", + "last_set": 0 + }, + "previous_names": [], + "num_members": 2 + }, + { + "id": "C0G9QKBBL", + "name": "general", + "is_channel": true, + "created": 1449709280, + "creator": "U0G9QF9C6", + "is_archived": false, + "is_general": true, + "name_normalized": "general", + "is_shared": false, + "is_org_shared": false, + "is_member": true, + "is_private": false, + "is_mpim": false, + "members": [ + "U0G9QF9C6", + "U0G9WFXNZ" + ], + "topic": { + "value": "Talk about anything!", + "creator": "U0G9QF9C6", + "last_set": 1449709364 + }, + "purpose": { + "value": "To talk about anything!", + "creator": "U0G9QF9C6", + "last_set": 1449709334 + }, + "previous_names": [], + "num_members": 2 + } + ], + "response_metadata": { + "next_cursor": "dGVhbTpDMUg5UkVTR0w=" + } +} diff --git a/test_resources/slack_users_response.json b/test_resources/slack_users_response.json new file mode 100644 index 0000000000000000000000000000000000000000..b91a795278dd46cad98294c79248eed426056724 --- /dev/null +++ b/test_resources/slack_users_response.json @@ -0,0 +1,86 @@ +{ + "ok": true, + "members": [ + { + "id": "W012A3CDE", + "team_id": "T012AB3C4", + "name": "spengler", + "deleted": false, + "color": "9f69e7", + "real_name": "spengler", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "avatar_hash": "ge3b51ca72de", + "status_text": "Print is dead", + "status_emoji": ":books:", + "real_name": "Egon Spengler", + "display_name": "spengler", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "spengler", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_32": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_48": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_72": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_192": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_512": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "team": "T012AB3C4" + }, + "is_admin": true, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "updated": 1502138686, + "is_app_user": false, + "has_2fa": false + }, + { + "id": "W07QCRPA4", + "team_id": "T0G9PQBBK", + "name": "glinda", + "deleted": false, + "color": "9f69e7", + "real_name": "Glinda Southgood", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "avatar_hash": "8fbdd10b41c6", + "image_24": "https://a.slack-edge.com...png", + "image_32": "https://a.slack-edge.com...png", + "image_48": "https://a.slack-edge.com...png", + "image_72": "https://a.slack-edge.com...png", + "image_192": "https://a.slack-edge.com...png", + "image_512": "https://a.slack-edge.com...png", + "image_1024": "https://a.slack-edge.com...png", + "image_original": "https://a.slack-edge.com...png", + "first_name": "Glinda", + "last_name": "Southgood", + "title": "Glinda the Good", + "phone": "", + "skype": "", + "real_name": "Glinda Southgood", + "real_name_normalized": "Glinda Southgood", + "display_name": "Glinda the Fairly Good", + "display_name_normalized": "Glinda the Fairly Good", + "email": "glenda@south.oz.coven" + }, + "is_admin": true, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "updated": 1480527098, + "has_2fa": false + } + ], + "cache_ts": 1498777272, + "response_metadata": { + "next_cursor": "dXNlcjpVMEc5V0ZYTlo=" + } +}