From 972435fbcee711888a406dfe1b7756ea9ebb839d Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Thu, 20 Feb 2020 17:57:02 -0800
Subject: [PATCH] Streaming API response tweaks (#11949)

* Fix streaming downloads -- send correct headers

* Don't include file download headers for normal QP API responses

* GZIP compress streaming responses

* Startup perf improvement
---
 src/metabase/api/common.clj                   |   6 +-
 src/metabase/api/common/internal.clj          |  47 +++--
 src/metabase/api/session.clj                  |   3 +-
 src/metabase/api/setting.clj                  |   6 +-
 src/metabase/async/streaming_response.clj     |  67 ++++---
 src/metabase/core.clj                         |   6 +-
 src/metabase/driver/util.clj                  |  27 ---
 src/metabase/middleware/misc.clj              |  33 ++--
 .../query_processor/streaming/json.clj        |   2 +-
 test/metabase/api/dashboard_test.clj          |  10 +-
 test/metabase/api/database_test.clj           |   7 +-
 test/metabase/api/dataset_test.clj            |  26 ++-
 test/metabase/api/geojson_test.clj            |  41 ++--
 test/metabase/api/session_test.clj            | 118 ++++++------
 test/metabase/api/setting_test.clj            | 177 ++++++++----------
 test/metabase/email_test.clj                  |   2 +-
 test/metabase/pulse_test.clj                  |   6 +-
 test/metabase/test/integrations/ldap.clj      |   4 +-
 18 files changed, 308 insertions(+), 280 deletions(-)

diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj
index 5f8b84a540b..020c03234f5 100644
--- a/src/metabase/api/common.clj
+++ b/src/metabase/api/common.clj
@@ -215,7 +215,7 @@
   [bindings & body]
   `(do-api-let ~generic-500 ~bindings ~@body))
 
-(def ^:const generic-204-no-content
+(def generic-204-no-content
   "A 'No Content' response for `DELETE` endpoints to return."
   {:status 204, :body nil})
 
@@ -231,11 +231,11 @@
    -  calls `auto-parse` to automatically parse certain args. e.g. `id` is converted from `String` to `Integer` via
       `Integer/parseInt`
 
-   -  converts ROUTE from a simple form like `\"/:id\"` to a typed one like `[\"/:id\" :id #\"[0-9]+\"]`
+   -  converts `route` from a simple form like `\"/:id\"` to a typed one like `[\"/:id\" :id #\"[0-9]+\"]`
 
    -  sequentially applies specified annotation functions on args to validate them.
 
-   -  automatically calls `wrap-response-if-needed` on the result of BODY
+   -  automatically calls `wrap-response-if-needed` on the result of `body`
 
    -  tags function's metadata in a way that subsequent calls to `define-routes` (see below) will automatically include
       the function in the generated `defroutes` form.
diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj
index 50a621b0874..c2ec790cbe1 100644
--- a/src/metabase/api/common/internal.clj
+++ b/src/metabase/api/common/internal.clj
@@ -3,7 +3,6 @@
    These are primarily used as the internal implementation of `defendpoint`."
   (:require [clojure.string :as str]
             [clojure.tools.logging :as log]
-            [medley.core :as m]
             [metabase
              [config :as config]
              [util :as u]]
@@ -11,6 +10,7 @@
             [metabase.util
              [i18n :as ui18n :refer [tru]]
              [schema :as su]]
+            [potemkin.types :as p.types]
             [schema.core :as s])
   (:import clojure.core.async.impl.channels.ManyToManyChannel
            metabase.async.streaming_response.StreamingResponse))
@@ -254,17 +254,36 @@
         (^String .replace "/" "_")
         symbol)))
 
-(defn wrap-response-if-needed
-  "If `response` isn't already a map with keys `:status` and `:body`, wrap it in one (using status 200)."
-  [response]
+(p.types/defprotocol+ EndpointResponse
+  "Protocol for transformations that should be done to the value returned by a `defendpoint` form before it
+  Compojure/Ring see it."
+  (wrap-response-if-needed [this]
+    "Transform the value returned by a `defendpoint` form as needed, e.g. by adding `:status` and `:body`."))
+
+(extend-protocol EndpointResponse
+  Object
+  (wrap-response-if-needed [this]
+    {:status 200, :body this})
+
+  nil
+  (wrap-response-if-needed [_]
+    {:status 204, :body nil})
+
+  StreamingResponse
+  (wrap-response-if-needed [this]
+    this)
+
+  ManyToManyChannel
+  (wrap-response-if-needed [chan]
+    {:status 202, :body chan})
+
+  clojure.lang.IPersistentMap
+  (wrap-response-if-needed [m]
+    (if (and (:status m) (contains? m :body))
+      m
+      {:status 200, :body m}))
+
   ;; Not sure why this is but the JSON serialization middleware barfs if response is just a plain boolean
-  (when (m/boolean? response)
-    (throw (Exception. (tru "Attempted to return a boolean as an API response. This is not allowed!"))))
-  (if (and (map? response)
-           (contains? response :status)
-           (contains? response :body))
-    response
-    {:status (if (some #(instance? % response) [ManyToManyChannel StreamingResponse])
-               202
-               200)
-     :body   response}))
+  Boolean
+  (wrap-response-if-needed [_]
+    (throw (Exception. (tru "Attempted to return a boolean as an API response. This is not allowed!")))))
diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj
index be9beeaea24..c55eca95c8c 100644
--- a/src/metabase/api/session.clj
+++ b/src/metabase/api/session.clj
@@ -183,7 +183,8 @@
       (let [reset-token        (user/set-password-reset-token! user-id)
             password-reset-url (str (public-settings/site-url) "/auth/reset_password/" reset-token)]
         (email/send-password-reset-email! email google-auth? server-name password-reset-url)
-        (log/info password-reset-url)))))
+        (log/info password-reset-url))))
+  api/generic-204-no-content)
 
 
 (def ^:private ^:const reset-token-ttl-ms
diff --git a/src/metabase/api/setting.clj b/src/metabase/api/setting.clj
index 5e87b2b4c49..4e5f85fb77c 100644
--- a/src/metabase/api/setting.clj
+++ b/src/metabase/api/setting.clj
@@ -15,7 +15,8 @@
   "Update multiple `Settings` values.  You must be a superuser to do this."
   [:as {settings :body}]
   (api/check-superuser)
-  (setting/set-many! settings))
+  (setting/set-many! settings)
+  api/generic-204-no-content)
 
 (api/defendpoint GET "/:key"
   "Fetch a single `Setting`. You must be a superuser to do this."
@@ -30,7 +31,8 @@
   [key :as {{:keys [value]} :body}]
   {key su/NonBlankString}
   (api/check-superuser)
-  (setting/set! key value))
+  (setting/set! key value)
+  api/generic-204-no-content)
 
 
 (api/define-routes)
diff --git a/src/metabase/async/streaming_response.clj b/src/metabase/async/streaming_response.clj
index cd5625d6bf2..b705fbae1a9 100644
--- a/src/metabase/async/streaming_response.clj
+++ b/src/metabase/async/streaming_response.clj
@@ -9,6 +9,7 @@
             [ring.util.response :as ring.response])
   (:import [java.io BufferedWriter FilterOutputStream OutputStream OutputStreamWriter]
            java.nio.charset.StandardCharsets
+           java.util.zip.GZIPOutputStream
            org.eclipse.jetty.io.EofException))
 
 (def ^:private keepalive-interval-ms
@@ -107,22 +108,33 @@
         (.flush writer))
       (catch Throwable _))))
 
-(defn- write-to-stream! [f {:keys [write-keepalive-newlines?]} ^OutputStream os finished-chan]
-  (with-open-chan [canceled-chan (a/promise-chan)]
-    (with-open [os os
-                os (jetty-eof-canceling-output-stream os canceled-chan)
-                os (keepalive-output-stream os write-keepalive-newlines?)]
-
-      (try
-        (f os canceled-chan)
-        (catch Throwable e
-          (write-error! os {:message (.getMessage e)}))
-        (finally
-          (.flush os)
-          (a/>!! finished-chan (if (a/poll! canceled-chan)
-                                 :canceled
-                                 :done))
-          (a/close! finished-chan))))))
+(defn- write-to-stream! [f {:keys [write-keepalive-newlines? gzip?], :as options} ^OutputStream os finished-chan]
+  (if gzip?
+    (with-open [gzos (GZIPOutputStream. os true)]
+      (write-to-stream! f (dissoc options :gzip?) gzos finished-chan))
+    (with-open-chan [canceled-chan (a/promise-chan)]
+      (with-open [os os
+                  os (jetty-eof-canceling-output-stream os canceled-chan)
+                  os (keepalive-output-stream os write-keepalive-newlines?)]
+
+        (try
+          (f os canceled-chan)
+          (catch Throwable e
+            (write-error! os {:message (.getMessage e)}))
+          (finally
+            (.flush os)
+            (a/>!! finished-chan (if (a/poll! canceled-chan)
+                                   :canceled
+                                   :done))
+            (a/close! finished-chan)))))))
+
+;; `ring.middleware.gzip` doesn't work on our StreamingResponse class.
+(defn- should-gzip-response?
+  "GZIP a response if the client accepts GZIP encoding, and, if quality is specified, quality > 0."
+  [{{:strs [accept-encoding]} :headers}]
+  (re-find #"gzip|\*" accept-encoding))
+
+(declare render)
 
 (p.types/deftype+ StreamingResponse [f options donechan]
   pretty/PrettyPrintable
@@ -134,13 +146,26 @@
   (write-body-to-stream [_ _ os]
     (write-to-stream! f options os donechan))
 
+  ;; sync responses only
+  compojure.response/Renderable
+  (render [this request]
+    (render this (should-gzip-response? request)))
+
   ;; async responses only
   compojure.response/Sendable
-  (send* [this _ respond _]
-    (respond (merge (ring.response/response this)
-                    {:content-type (:content-type options)
-                     :headers      (:headers options)
-                     :status       202}))))
+  (send* [this request respond _]
+    (respond (compojure.response/render this request))))
+
+(defn- render [^StreamingResponse streaming-response gzip?]
+  (let [{:keys [headers content-type], :as options} (.options streaming-response)]
+    (assoc (ring.response/response (if gzip?
+                                     (StreamingResponse. (.f streaming-response)
+                                                         (assoc options :gzip? true)
+                                                         (.donechan streaming-response))
+                                     streaming-response))
+           :headers      (cond-> (assoc headers "Content-Type" content-type)
+                           gzip? (assoc "Content-Encoding" "gzip"))
+           :status       202)))
 
 (defn finished-chan
   "Fetch a promise channel that will get a message when a `StreamingResponse` is completely finished. Provided primarily
diff --git a/src/metabase/core.clj b/src/metabase/core.clj
index 0cd1da1d276..78ecc4fd268 100644
--- a/src/metabase/core.clj
+++ b/src/metabase/core.clj
@@ -1,4 +1,3 @@
-;; -*- comment-column: 35; -*-
 (ns metabase.core
   (:gen-class)
   (:require [clojure.string :as str]
@@ -19,7 +18,6 @@
              [troubleshooting :as troubleshooting]
              [util :as u]]
             [metabase.core.initialization-status :as init-status]
-            [metabase.driver.util :as driver.u]
             [metabase.models
              [setting :as setting]
              [user :refer [User]]]
@@ -70,8 +68,8 @@
   (plugins/load-plugins!)
   (init-status/set-progress! 0.3)
 
-  ;; Load up all of our Database drivers, which are used for app db work
-  (driver.u/find-and-load-all-drivers!)
+  ;; Load up the drivers shipped as part of the main codebase, so they will show up in the list of available DB types
+  (classloader/require 'metabase.driver.h2 'metabase.driver.postgres 'metabase.driver.mysql)
   (init-status/set-progress! 0.4)
 
   ;; startup database.  validates connection & runs any necessary migrations
diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj
index c148c144440..13c811771d0 100644
--- a/src/metabase/driver/util.clj
+++ b/src/metabase/driver/util.clj
@@ -1,13 +1,11 @@
 (ns metabase.driver.util
   "Utility functions for common operations on drivers."
   (:require [clojure.core.memoize :as memoize]
-            [clojure.string :as str]
             [clojure.tools.logging :as log]
             [metabase
              [config :as config]
              [driver :as driver]
              [util :as u]]
-            [metabase.driver.impl :as impl]
             [metabase.util.i18n :refer [trs]]
             [toucan.db :as db]))
 
@@ -68,31 +66,6 @@
   (memoize/ttl database->driver* :ttl/threshold 1000))
 
 
-;;; +----------------------------------------------------------------------------------------------------------------+
-;;; |                                              Loading all Drivers                                               |
-;;; +----------------------------------------------------------------------------------------------------------------+
-
-(defn find-and-load-all-drivers!
-  "Search classpath for namespaces that start with `metabase.driver.`, then `require` them, which should register them
-  as a side-effect. Note that this will not load drivers added by 3rd-party plugins; they must register themselves
-  appropriately when initialized by `load-plugins!`.
-
-  This really only needs to be done by the public settings API endpoint to populate the list of available drivers.
-  Please avoid using this function elsewhere, as loading all of these namespaces can be quite expensive!"
-  []
-  (doseq [ns-symb u/metabase-namespace-symbols
-          :when   (re-matches #"^metabase\.driver\.[a-z0-9_]+$" (name ns-symb))
-          :let    [driver (keyword (-> (last (str/split (name ns-symb) #"\."))
-                                       (str/replace #"_" "-")))]
-          ;; let's go ahead and ignore namespaces we know for a fact do not contain drivers
-          :when   (not (#{:common :util :query-processor :google :impl}
-                        driver))]
-    (try
-      (impl/load-driver-namespace-if-needed! driver)
-      (catch Throwable e
-        (log/error e (trs "Error loading namespace"))))))
-
-
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                             Available Drivers Info                                             |
 ;;; +----------------------------------------------------------------------------------------------------------------+
diff --git a/src/metabase/middleware/misc.clj b/src/metabase/middleware/misc.clj
index d5d4e950d70..f86dc4ae3aa 100644
--- a/src/metabase/middleware/misc.clj
+++ b/src/metabase/middleware/misc.clj
@@ -14,26 +14,23 @@
 
 (comment metabase.async.streaming-response/keep-me)
 
-(defn- add-content-type* [request response]
-  (update-in
-   response
-   [:headers "Content-Type"]
-   (fn [content-type]
-     (or content-type
-         (when (middleware.u/api-call? request)
-           (if (string? (:body response))
-             "text/plain"
-             "application/json; charset=utf-8"))))))
+(defn- add-content-type* [request {:keys [body], {:strs [Content-Type]} :headers, :as response}]
+  (cond-> response
+    (not Content-Type)
+    (assoc-in [:headers "Content-Type"] (if (string? body)
+                                          "text/plain"
+                                          "application/json; charset=utf-8"))))
 
 (defn add-content-type
   "Add an appropriate Content-Type header to response if it doesn't already have one. Most responses should already
   have one, so this is a fallback for ones that for one reason or another do not."
   [handler]
   (fn [request respond raise]
-    (handler
-     request
-     (comp respond (partial add-content-type* request))
-     raise)))
+    (handler request
+             (if-not (middleware.u/api-call? request)
+               respond
+               (comp respond (partial add-content-type* request)))
+             raise)))
 
 
 ;;; ------------------------------------------------ SETTING SITE-URL ------------------------------------------------
@@ -81,10 +78,10 @@
 ;;; ------------------------------------------ Disable Streaming Buffering -------------------------------------------
 
 (defn- maybe-add-disable-buffering-header [{:keys [body], :as response}]
-  (if (or (instance? StreamingResponse body)
-          (instance? ManyToManyChannel body))
-    (assoc-in response [:headers "X-Accel-Buffering"] "no")
-    response))
+  (cond-> response
+    (or (instance? StreamingResponse body)
+        (instance? ManyToManyChannel body))
+    (assoc-in [:headers "X-Accel-Buffering"] "no")))
 
 (defn disable-streaming-buffering
   "Tell nginx not to batch streaming responses -- otherwise the keepalive bytes aren't written and
diff --git a/src/metabase/query_processor/streaming/json.clj b/src/metabase/query_processor/streaming/json.clj
index 064ae19b96d..2b6075f0405 100644
--- a/src/metabase/query_processor/streaming/json.clj
+++ b/src/metabase/query_processor/streaming/json.clj
@@ -40,7 +40,7 @@
 
 (defmethod i/stream-options :api
   [stream-type]
-  ((get-method i/stream-options :json) stream-type))
+  {:content-type "applicaton/json; charset=utf-8"})
 
 (defn- map->serialized-json-kvs
   "{:a 100, :b 200} ; -> \"a\":100,\"b\":200"
diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj
index 570d4dcd677..9bc6a5c1c6c 100644
--- a/test/metabase/api/dashboard_test.clj
+++ b/test/metabase/api/dashboard_test.clj
@@ -584,13 +584,13 @@
 ;;; |                                           DELETE /api/dashboard/:id                                            |
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
-(expect
-  [nil nil]
+(deftest delete-test
   (tt/with-temp Dashboard [{dashboard-id :id}]
     (with-dashboards-in-writeable-collection [dashboard-id]
-      [((user->client :rasta) :delete 204 (format "dashboard/%d" dashboard-id))
-       (Dashboard dashboard-id)])))
-
+      (is (= nil
+             ((user->client :rasta) :delete 204 (format "dashboard/%d" dashboard-id))))
+      (is (= nil
+             (Dashboard dashboard-id))))))
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                         COPY /api/dashboard/:id/copy                                           |
diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj
index 71b6d5f1279..01aeacd6c6c 100644
--- a/test/metabase/api/database_test.clj
+++ b/test/metabase/api/database_test.clj
@@ -475,10 +475,11 @@
            ((test-users/user->client :crowberto) :get 200
             (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))))))
 
-;; if no eligible Saved Questions exist the virtual DB metadata endpoint should just return `nil`
 (deftest return-nil-when-no-eligible-saved-questions
-  (is (nil? ((test-users/user->client :crowberto) :get 200
-             (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id)))))
+  (testing "if no eligible Saved Questions exist the virtual DB metadata endpoint should just return `nil`"
+    (is (= nil
+           ((test-users/user->client :crowberto) :get 204
+            (format "database/%d/metadata" mbql.s/saved-questions-virtual-database-id))))))
 
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj
index 242bd1a0b2d..7a169453df1 100644
--- a/test/metabase/api/dataset_test.clj
+++ b/test/metabase/api/dataset_test.clj
@@ -3,11 +3,14 @@
   (:require [cheshire
              [core :as json]
              [generate :as generate]]
+            [clojure
+             [string :as str]
+             [test :refer :all]]
             [clojure.data.csv :as csv]
-            [clojure.test :refer :all]
             [dk.ative.docjure.spreadsheet :as spreadsheet]
             [medley.core :as m]
             [metabase
+             [http-client :as http-client]
              [query-processor-test :as qp.test]
              [test :as mt]
              [util :as u]]
@@ -189,6 +192,21 @@
                        (json/generate-string (data/mbql-query checkins)))]
            (take 5 (parse-and-sort-csv result))))))
 
+(deftest download-response-headers-test
+  (testing "Make sure CSV/etc. download requests come back with the correct headers"
+    (is (= {"Cache-Control"       "max-age=0, no-cache, must-revalidate, proxy-revalidate"
+            "Content-Disposition" "attachment; filename=\"query_result_<timestamp>.csv\""
+            "Content-Type"        "text/csv"
+            "Expires"             "Tue, 03 Jul 2001 06:00:00 GMT"
+            "X-Accel-Buffering"   "no"}
+           (-> (http-client/client-full-response (test-users/username->token :rasta)
+                                                 :post 202 "dataset/csv"
+                                                 :query (json/generate-string (data/mbql-query checkins {:limit 1})))
+               :headers
+               (select-keys ["Cache-Control" "Content-Disposition" "Content-Type" "Expires" "X-Accel-Buffering"])
+               (update "Content-Disposition" #(some-> % (str/replace #"query_result_.+(\.\w+)"
+                                                                                   "query_result_<timestamp>$1"))))))))
+
 (deftest check-an-empty-date-column
   (is (= [["1" "2014-04-07" "" "5" "12"]
           ["2" "2014-09-18" "" "1" "31"]
@@ -196,9 +214,9 @@
           ["4" "2014-03-11" "" "5" "4"]
           ["5" "2013-05-05" "" "3" "49"]]
          (data/dataset defs/test-data-with-null-date-checkins
-                       (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query
-                                     (json/generate-string (data/mbql-query checkins)))]
-                         (take 5 (parse-and-sort-csv result)))))))
+           (let [result ((test-users/user->client :rasta) :post 202 "dataset/csv" :query
+                         (json/generate-string (data/mbql-query checkins)))]
+             (take 5 (parse-and-sort-csv result)))))))
 
 (deftest sqlite-datetime-test
   (mt/test-driver :sqlite
diff --git a/test/metabase/api/geojson_test.clj b/test/metabase/api/geojson_test.clj
index 5c3b65b6f3b..d02e8608ffc 100644
--- a/test/metabase/api/geojson_test.clj
+++ b/test/metabase/api/geojson_test.clj
@@ -35,17 +35,19 @@
 
 (deftest invalid-url-test
   (testing "test that you're not allowed to set invalid URLs"
-    (is (thrown? Exception
-                 (geojson-api/custom-geojson {:name        "Middle Earth"
-                                              :url         "ABC"
-                                              :region_key  nil
-                                              :region_name nil})))
+    (is (thrown?
+         Exception
+         (geojson-api/custom-geojson {:name        "Middle Earth"
+                                      :url         "ABC"
+                                      :region_key  nil
+                                      :region_name nil})))
 
-    (is (thrown? Exception
-                 (geojson-api/custom-geojson {:name        "Middle Earth"
-                                              :url         "http://google.com"
-                                              :region_key  nil
-                                              :region_name nil})))))
+    (is (thrown?
+         Exception
+         (geojson-api/custom-geojson {:name        "Middle Earth"
+                                      :url         "http://google.com"
+                                      :region_key  nil
+                                      :region_name nil})))))
 
 (deftest update-endpoint-test
   (testing "PUT /api/setting/custom-geojson"
@@ -56,24 +58,27 @@
                ;; bind a temporary value so it will get set back to its old value here after the API calls are done
                ;; stomping all over it
                (mt/with-temporary-setting-values [custom-geojson nil]
-                 ((mt/user->client :crowberto) :put 200 "setting/custom-geojson" {:value test-custom-geojson})
+                 ((mt/user->client :crowberto) :put 204 "setting/custom-geojson" {:value test-custom-geojson})
                  ((mt/user->client :crowberto) :get 200 "setting/custom-geojson"))))))
+
     (testing "error conditions"
       (letfn [(put-bad-url [url]
                 (mt/suppress-output
-                  ;; try this up to 3 times since Circle's outbound connections likes to randomly stop working
-                  (u/auto-retry 3
-                    ;; bind a temporary value so it will get set back to its old value here after the API calls are done
-                    ;; stomping all over it
-                    (mt/with-temporary-setting-values [custom-geojson nil]
-                      (let [url (assoc-in test-custom-geojson [:middle-earth :url] url)]
-                        (:message ((mt/user->client :crowberto) :put 500 "setting/custom-geojson" {:value url})))))))]
+                 ;; try this up to 3 times since Circle's outbound connections likes to randomly stop working
+                 (u/auto-retry 3
+                               ;; bind a temporary value so it will get set back to its old value here after the API calls are done
+                               ;; stomping all over it
+                               (mt/with-temporary-setting-values [custom-geojson nil]
+                                 (let [url (assoc-in test-custom-geojson [:middle-earth :url] url)]
+                                   (:message ((mt/user->client :crowberto) :put 500 "setting/custom-geojson" {:value url})))))))]
         (testing "Test that a bad url will return a descriptive error message"
           (is (re= #"^Unable to retrieve resource.*"
                    (put-bad-url "https://raw.githubusercontent.com/metabase/metabase/master/test_resources/something-random"))))
+
         (testing "Test that a bad host will return a connection refused error"
           (is (re= #"^Unable to connect.*"
                    (put-bad-url "https://somethingrandom.metabase.com"))))
+
         (testing "Test out the error message for a relative path file we can't find"
           (is (re= #"^Unable to find JSON via relative path.*"
                    (put-bad-url "some/relative/path"))))))))
diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj
index dffa272dffb..61f0e404583 100644
--- a/test/metabase/api/session_test.clj
+++ b/test/metabase/api/session_test.clj
@@ -51,8 +51,6 @@
   (client :post 400 "session" (-> (test-users/user->credentials :rasta)
                                   (assoc :password "something else"))))
 
-;;
-;;
 (deftest login-throttling-test
   (testing (str "Test that people get blocked from attempting to login if they try too many times (Check that"
                 " throttling works at the API level -- more tests in the throttle library itself:"
@@ -131,65 +129,67 @@
         (is (re= #"^Too many attempts! You must wait 4\d seconds before trying again\.$"
                  (error)))))))
 
-
-;; ## DELETE /api/session
-;; Test that we can logout
-(expect
-  nil
-  (do
-    ;; clear out cached session tokens so next time we make an API request it log in & we'll know we have a valid
-    ;; Session
-    (test-users/clear-cached-session-tokens!)
-    (let [session-id (test-users/username->token :rasta)]
-      ;; Ok, calling the logout endpoint should delete the Session in the DB. Don't worry, `test-users` will log back
-      ;; in on the next API call
-      ((test-users/user->client :rasta) :delete 204 "session")
-      ;; check whether it's still there -- should be GONE
-      (Session session-id))))
-
-
-;; ## POST /api/session/forgot_password
-;; Test that we can initiate password reset
-(expect
-  (et/with-fake-inbox
-    (let [reset-fields-set? (fn []
-                              (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered]
-                                                                            :id (test-users/user->id :rasta))]
-                                (boolean (and reset_token reset_triggered))))]
-      ;; make sure user is starting with no values
-      (db/update! User (test-users/user->id :rasta), :reset_token nil, :reset_triggered nil)
-      (assert (not (reset-fields-set?)))
-      ;; issue reset request (token & timestamp should be saved)
-      ((test-users/user->client :rasta) :post 200 "session/forgot_password" {:email (:username (test-users/user->credentials :rasta))})
-      ;; TODO - how can we test email sent here?
-      (reset-fields-set?))))
-
-;; Test that email is required
-(expect {:errors {:email "value must be a valid email address."}}
-  (client :post 400 "session/forgot_password" {}))
-
-;; Test that email not found also gives 200 as to not leak existence of user
-(expect nil
-  (client :post 200 "session/forgot_password" {:email "not-found@metabase.com"}))
-
-;; Test that email based throttling kicks in after the login failure threshold (10) has been reached
-(defn- send-password-reset [& [expected-status & more]]
-  (client :post (or expected-status 200) "session/forgot_password" {:email "not-found@metabase.com"}))
+(deftest logout-test
+  (testing "DELETE /api/session"
+    (testing "Test that we can logout"
+      ;; clear out cached session tokens so next time we make an API request it log in & we'll know we have a valid
+      ;; Session
+      (test-users/clear-cached-session-tokens!)
+      (let [session-id (test-users/username->token :rasta)]
+        ;; Ok, calling the logout endpoint should delete the Session in the DB. Don't worry, `test-users` will log back
+        ;; in on the next API call
+        ((test-users/user->client :rasta) :delete 204 "session")
+        ;; check whether it's still there -- should be GONE
+        (is (= nil
+               (Session session-id)))))))
+
+(deftest forgot-password-test
+  (testing "POST /api/session/forgot_password"
+    (testing "Test that we can initiate password reset"
+      (et/with-fake-inbox
+        (letfn [(reset-fields-set? []
+                  (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered]
+                                                                :id (test-users/user->id :rasta))]
+                    (boolean (and reset_token reset_triggered))))]
+          ;; make sure user is starting with no values
+          (db/update! User (test-users/user->id :rasta), :reset_token nil, :reset_triggered nil)
+          (assert (not (reset-fields-set?)))
+          ;; issue reset request (token & timestamp should be saved)
+          (is (= nil
+                 ((test-users/user->client :rasta) :post 204 "session/forgot_password" {:email (:username (test-users/user->credentials :rasta))}))
+              "Request should return no content")
+          (is (= true
+                 (reset-fields-set?))
+              "User `:reset_token` and `:reset_triggered` should be updated")
+          (is (= "[Metabase] Password Reset Request"
+                 (-> @et/inbox (get "rasta@metabase.com") first :subject))
+              "User should get a password reset email"))))
+
+    (testing "test that email is required"
+      (is (= {:errors {:email "value must be a valid email address."}}
+             (client :post 400 "session/forgot_password" {}))))
+
+    (testing "Test that email not found also gives 200 as to not leak existence of user"
+      (is (= nil
+             (client :post 204 "session/forgot_password" {:email "not-found@metabase.com"}))))))
 
 (deftest forgot-password-throttling-test
-  (with-redefs [session-api/forgot-password-throttlers (cleaned-throttlers #'session-api/forgot-password-throttlers
-                                                                           [:email :ip-address])]
-    (dotimes [n 10]
-      (send-password-reset))
-    (let [error (fn []
-                  (-> (send-password-reset 400)
-                      :errors
-                      :email))]
-      (is (= "Too many attempts! You must wait 15 seconds before trying again."
-             (error)))
-      ;;`throttling/check` gives 15 in stead of 42
-      (is (= "Too many attempts! You must wait 15 seconds before trying again."
-             (error))))))
+  (testing "Test that email based throttling kicks in after the login failure threshold (10) has been reached"
+    (letfn [(send-password-reset [& [expected-status & more]]
+              (client :post (or expected-status 204) "session/forgot_password" {:email "not-found@metabase.com"}))]
+      (with-redefs [session-api/forgot-password-throttlers (cleaned-throttlers #'session-api/forgot-password-throttlers
+                                                                               [:email :ip-address])]
+        (dotimes [n 10]
+          (send-password-reset))
+        (let [error (fn []
+                      (-> (send-password-reset 400)
+                          :errors
+                          :email))]
+          (is (= "Too many attempts! You must wait 15 seconds before trying again."
+                 (error)))
+          ;;`throttling/check` gives 15 in stead of 42
+          (is (= "Too many attempts! You must wait 15 seconds before trying again."
+                 (error))))))))
 
 
 ;; POST /api/session/reset_password
diff --git a/test/metabase/api/setting_test.clj b/test/metabase/api/setting_test.clj
index 0f32abb24f8..ceb736d305e 100644
--- a/test/metabase/api/setting_test.clj
+++ b/test/metabase/api/setting_test.clj
@@ -1,7 +1,7 @@
 (ns metabase.api.setting-test
   (:require [clojure.test :refer :all]
-            [expectations :refer [expect]]
             [metabase.models.setting-test :refer [test-sensitive-setting test-setting-1 test-setting-2 test-setting-3]]
+            [metabase.test :as mt]
             [metabase.test.data.users :as test-users]
             [metabase.test.fixtures :as fixtures]))
 
@@ -16,106 +16,93 @@
 (defn- fetch-setting [setting-name status]
   ((test-users/user->client :crowberto) :get status (format "setting/%s" (name setting-name))))
 
-;; ## GET /api/setting
-;; Check that we can fetch all Settings for Org, except `:visiblity :internal` ones
-(expect
-  [{:key            "test-setting-1"
-    :value          nil
-    :is_env_setting true
-    :env_name       "MB_TEST_SETTING_1"
-    :description    "Test setting - this only shows up in dev (1)"
-    :default        "Using value of env var $MB_TEST_SETTING_1"}
-   {:key            "test-setting-2"
-    :value          "FANCY"
-    :is_env_setting false
-    :env_name       "MB_TEST_SETTING_2"
-    :description    "Test setting - this only shows up in dev (2)"
-    :default        "[Default Value]"}]
-  (do
-    (test-setting-1 nil)
-    (test-setting-2 "FANCY")
-    ; internal setting that should not be returned:
-    (test-setting-3 "oh hai")
-    (fetch-test-settings)))
-
-;; Check that non-superusers are denied access
-(expect "You don't have permissions to do that."
-  ((test-users/user->client :rasta) :get 403 "setting"))
-
-
-;; ## GET /api/setting/:key
-;; Test that we can fetch a single setting
-(expect
- "OK!"
- (do (test-setting-2 "OK!")
-     (fetch-setting :test-setting-2 200)))
-
-;; Test that we can't fetch internal settings
-(expect
- "Setting :test-setting-3 is internal"
- (do (test-setting-3 "NOPE!")
-     (:message (fetch-setting :test-setting-3 500))))
+(deftest fetch-setting-test
+  (testing "GET /api/setting"
+    (testing "Check that we can fetch all Settings, except `:visiblity :internal` ones"
+      (test-setting-1 nil)
+      (test-setting-2 "FANCY")
+      (test-setting-3 "oh hai")         ; internal setting that should not be returned
+      (is (= [{:key            "test-setting-1"
+               :value          nil
+               :is_env_setting true
+               :env_name       "MB_TEST_SETTING_1"
+               :description    "Test setting - this only shows up in dev (1)"
+               :default        "Using value of env var $MB_TEST_SETTING_1"}
+              {:key            "test-setting-2"
+               :value          "FANCY"
+               :is_env_setting false
+               :env_name       "MB_TEST_SETTING_2"
+               :description    "Test setting - this only shows up in dev (2)"
+               :default        "[Default Value]"}]
+             (fetch-test-settings))))
+
+    (testing "Check that non-superusers are denied access"
+      (is (= "You don't have permissions to do that."
+             ((test-users/user->client :rasta) :get 403 "setting")))))
+
+  (testing "GET /api/setting/:key"
+    (testing "Test that we can fetch a single setting"
+      (test-setting-2 "OK!")
+      (is (= "OK!"
+             (fetch-setting :test-setting-2 200))))))
+
+(deftest fetch-internal-settings-test
+  (testing "Test that we can't fetch internal settings"
+    (test-setting-3 "NOPE!")
+    (is (= "Setting :test-setting-3 is internal"
+           (mt/suppress-output
+             (:message (fetch-setting :test-setting-3 500)))))))
 
 (deftest update-settings-test
   (testing "PUT /api/setting/:key"
-    ((test-users/user->client :crowberto) :put 200 "setting/test-setting-1" {:value "NICE!"})
+    ((test-users/user->client :crowberto) :put 204 "setting/test-setting-1" {:value "NICE!"})
     (is (= "NICE!"
            (test-setting-1))
         "Updated setting should be visible from setting getter")
 
     (is (= "NICE!"
            (fetch-setting :test-setting-1 200))
-        "Updated setting should be visible from API endpoint")))
-
-;; ## Check non-superuser can't set a Setting
-(expect "You don't have permissions to do that."
-  ((test-users/user->client :rasta) :put 403 "setting/test-setting-1" {:value "NICE!"}))
-
-
-;;; ----------------------------------------------- Sensitive Settings -----------------------------------------------
-
-;; Sensitive settings should always come back obfuscated
-
-;; GET /api/setting/:name should obfuscate sensitive settings
-(expect
-  "**********EF"
-  (do
-    (test-sensitive-setting "ABCDEF")
-    (fetch-setting :test-sensitive-setting 200)))
-
-;; GET /api/setting should obfuscate sensitive settings
-(expect
-  {:key            "test-sensitive-setting"
-   :value          "**********LM"
-   :is_env_setting false
-   :env_name       "MB_TEST_SENSITIVE_SETTING"
-   :description    "This is a sample sensitive Setting."
-   :default        nil}
-  (do
-    (test-sensitive-setting "GHIJKLM")
-    (some (fn [{setting-name :key, :as setting}]
-            (when (= setting-name "test-sensitive-setting")
-              setting))
-          ((test-users/user->client :crowberto) :get 200 "setting"))))
-
-;; Setting the Setting via an endpoint should still work as expected; the normal getter functions should *not*
-;; obfuscate sensitive Setting values -- that should be done by the API
-(expect
-  "123456"
-  (do
-    ((test-users/user->client :crowberto) :put 200 "setting/test-sensitive-setting" {:value "123456"})
-    (test-sensitive-setting)))
-
-;; Attempts to set the Setting to an obfuscated value should be ignored
-(expect
-  "123456"
-  (do
-    (test-sensitive-setting "123456")
-    ((test-users/user->client :crowberto) :put 200 "setting/test-sensitive-setting" {:value "**********56"})
-    (test-sensitive-setting)))
-
-(expect
-  (do
-    (test-sensitive-setting "123456")
-    ((test-users/user->client :crowberto) :put 200 "setting" {:test-sensitive-setting "**********56"})
-    (test-sensitive-setting)))
+        "Updated setting should be visible from API endpoint")
+
+    (testing "Check non-superuser can't set a Setting"
+      (= "You don't have permissions to do that."
+         ((test-users/user->client :rasta) :put 403 "setting/test-setting-1" {:value "NICE!"})))))
+
+(deftest fetch-sensitive-setting-test
+  (testing "Sensitive settings should always come back obfuscated"
+    (testing "GET /api/setting/:name"
+      (test-sensitive-setting "ABCDEF")
+      (is (= "**********EF"
+             (fetch-setting :test-sensitive-setting 200))))
+
+    (testing "GET /api/setting"
+      (test-sensitive-setting "GHIJKLM")
+      (is (= {:key            "test-sensitive-setting"
+               :value          "**********LM"
+               :is_env_setting false
+               :env_name       "MB_TEST_SENSITIVE_SETTING"
+               :description    "This is a sample sensitive Setting."
+              :default        nil}
+             (some (fn [{setting-name :key, :as setting}]
+                     (when (= setting-name "test-sensitive-setting")
+                       setting))
+                   ((test-users/user->client :crowberto) :get 200 "setting")))))))
+
+(deftest set-sensitive-setting-test
+  (testing "Setting the Setting via an endpoint should still work as expected; the normal getter functions should *not* obfuscate sensitive Setting values -- that should be done by the API"
+    ((test-users/user->client :crowberto) :put 204 "setting/test-sensitive-setting" {:value "123456"})
+    (is (= "123456"
+           (test-sensitive-setting))))
+
+  (testing "Attempts to set the Setting to an obfuscated value should be ignored"
+    (testing "PUT /api/setting/:name"
+      (test-sensitive-setting "123456")
+      ((test-users/user->client :crowberto) :put 204 "setting/test-sensitive-setting" {:value "**********56"})
+      (is (= "123456"
+             (test-sensitive-setting))))
+
+    (testing "PUT /api/setting"
+      (test-sensitive-setting "123456")
+      ((test-users/user->client :crowberto) :put 204 "setting" {:test-sensitive-setting "**********56"})
+      (is (= "123456"
+             (test-sensitive-setting))))))
diff --git a/test/metabase/email_test.clj b/test/metabase/email_test.clj
index e72bcb97893..c540d53e486 100644
--- a/test/metabase/email_test.clj
+++ b/test/metabase/email_test.clj
@@ -69,7 +69,7 @@
 
 (defmacro with-fake-inbox
   "Clear `inbox`, bind `send-email!` to `fake-inbox-email-fn`, set temporary settings for `email-smtp-username`
-   and `email-smtp-password` (which will cause `metabase.email/email-configured?` to return `true`, and execute BODY.
+   and `email-smtp-password` (which will cause `metabase.email/email-configured?` to return `true`, and execute `body`.
 
    Fetch the emails send by dereffing `inbox`.
 
diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj
index 8bbe1a64a6a..add3c4c0b66 100644
--- a/test/metabase/pulse_test.clj
+++ b/test/metabase/pulse_test.clj
@@ -908,6 +908,8 @@
       (is (= [[1 "2014-04-07T00:00:00Z" 5 12]]
              (send-pulse-created-by-user! :crowberto)))
       (is (thrown-with-msg?
-           clojure.lang.ExceptionInfo #"^You do not have permissions to view Card [\d,]+."
-           (send-pulse-created-by-user! :rasta))
+           clojure.lang.ExceptionInfo
+           #"^You do not have permissions to view Card [\d,]+."
+           (mt/suppress-output
+             (send-pulse-created-by-user! :rasta)))
           "If the current user doesn't have permissions to execute the Card for a Pulse, an Exception should be thrown."))))
diff --git a/test/metabase/test/integrations/ldap.clj b/test/metabase/test/integrations/ldap.clj
index 2a94dafa6ab..bacbc0ff273 100644
--- a/test/metabase/test/integrations/ldap.clj
+++ b/test/metabase/test/integrations/ldap.clj
@@ -18,9 +18,9 @@
 
 (defn- start-ldap-server! []
   (with-open [ldif (LDIFReader. (or (io/file (io/resource "ldap.ldif"))
+                                    (io/file "test_resources/ldap.ldif")
                                     (throw
-                                     (FileNotFoundException.
-                                      "ldap.ldif does not exist! (You may need to copy it into your resources dir.)"))))]
+                                     (FileNotFoundException. "ldap.ldif does not exist!"))))]
     (doto (InMemoryDirectoryServer. (get-server-config))
       (.importFromLDIF true ldif)
       (.startListening))))
-- 
GitLab