From 32dbd9eb21796625fe87faf4dd710c5cd17e1af5 Mon Sep 17 00:00:00 2001
From: Cam Saul <cammsaul@gmail.com>
Date: Mon, 29 Apr 2019 20:40:06 -0700
Subject: [PATCH] CSV/JSON/XLSX downloads should not be subject to default
 constraints :1234:

---
 src/metabase/api/card.clj                     | 12 +--
 src/metabase/api/common.clj                   | 11 ++-
 src/metabase/api/dataset.clj                  |  2 +
 src/metabase/api/pulse.clj                    | 11 ++-
 src/metabase/api/tiles.clj                    | 34 ++++++--
 src/metabase/pulse.clj                        | 32 ++++----
 .../middleware/constraints.clj                | 27 ++++---
 src/metabase/server.clj                       | 21 ++---
 test/metabase/api/card_test.clj               | 39 ++++++++-
 test/metabase/api/dataset_test.clj            | 50 +++++++++---
 test/metabase/api/pulse_test.clj              | 17 +++-
 test/metabase/api/tiles_test.clj              | 46 ++++++++---
 test/metabase/pulse_test.clj                  | 79 +++++++++++--------
 .../middleware/constraints_test.clj           | 58 ++++++++++++++
 test/metabase/task/sync_databases_test.clj    | 20 +++--
 15 files changed, 334 insertions(+), 125 deletions(-)
 create mode 100644 test/metabase/query_processor/middleware/constraints_test.clj

diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj
index c4c7b671620..0bc8a0c602f 100644
--- a/src/metabase/api/card.clj
+++ b/src/metabase/api/card.clj
@@ -595,11 +595,13 @@
                   (u/emoji "💾"))
         ttl-seconds))))
 
-(defn- query-for-card [card parameters constraints middleware]
-  (let [query (assoc (:dataset_query card)
-                :constraints constraints
-                :parameters  parameters
-                :middleware  middleware)
+(defn- query-for-card [{query :dataset_query, :as card} parameters constraints middleware]
+  (let [query (-> query
+                  ;; don't want default constraints overridding anything that's already there
+                  (m/dissoc-in [:middleware :add-default-userland-constraints?])
+                  (assoc :constraints constraints
+                         :parameters  parameters
+                         :middleware  middleware))
         ttl   (when (public-settings/enable-query-caching)
                 (or (:cache_ttl card)
                     (query-magic-ttl query)))]
diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj
index 24d5a4666f0..e75beddc48a 100644
--- a/src/metabase/api/common.clj
+++ b/src/metabase/api/common.clj
@@ -260,13 +260,16 @@
 
    -  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]+\"]`
+
    -  sequentially applies specified annotation functions on args to validate them.
-   -  executes BODY inside a `try-catch` block that handles exceptions; if exception is an instance of `ExceptionInfo`
-      and includes a `:status-code`, that code will be returned
+
    -  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.
+
+   -  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.
+
    -  Generates a super-sophisticated Markdown-formatted docstring"
   {:arglists '([method route docstr? args schemas-map? & body])}
   [method route & more]
diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj
index b376f000b54..85e5507eee6 100644
--- a/src/metabase/api/dataset.clj
+++ b/src/metabase/api/dataset.clj
@@ -5,6 +5,7 @@
             [clojure.string :as str]
             [clojure.tools.logging :as log]
             [compojure.core :refer [POST]]
+            [medley.core :as m]
             [metabase.api.common :as api]
             [metabase.models
              [card :refer [Card]]
@@ -147,6 +148,7 @@
       (qp.async/process-query-and-save-execution!
        (-> query
            (dissoc :constraints)
+           (m/dissoc-in [:middleware :add-default-userland-constraints?])
            (assoc-in [:middleware :skip-results-metadata?] true))
        {:executed-by api/*current-user-id*, :context (export-format->context export-format)}))))
 
diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj
index 7941a535449..9e706fcef96 100644
--- a/src/metabase/api/pulse.clj
+++ b/src/metabase/api/pulse.clj
@@ -134,10 +134,13 @@
                    (catch Throwable e
                      (assoc-in chan-types [:slack :error] (.getMessage e)))))}))
 
-(defn- pulse-card-query-results [card]
-  (qp/process-query-and-save-execution! (:dataset_query card) {:executed-by api/*current-user-id*
-                                                               :context     :pulse
-                                                               :card-id     (u/get-id card)}))
+(defn- pulse-card-query-results
+  {:arglists '([card])}
+  [{query :dataset_query, card-id :id}]
+  (qp/process-query-and-save-execution! (assoc query :async? false)
+    {:executed-by api/*current-user-id*
+     :context     :pulse
+     :card-id     card-id}))
 
 (api/defendpoint GET "/preview_card/:id"
   "Get HTML rendering of a Card with `id`."
diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj
index a2c9be71ef5..4b0d1c8e7b6 100644
--- a/src/metabase/api/tiles.clj
+++ b/src/metabase/api/tiles.clj
@@ -14,7 +14,7 @@
              [schema :as su]])
   (:import java.awt.Color
            java.awt.image.BufferedImage
-           java.io.ByteArrayOutputStream
+           [java.io ByteArrayInputStream ByteArrayOutputStream]
            javax.imageio.ImageIO))
 
 ;;; --------------------------------------------------- CONSTANTS ----------------------------------------------------
@@ -116,6 +116,7 @@
 
 ;;; ---------------------------------------------------- ENDPOINT ----------------------------------------------------
 
+;; TODO - this can be reworked to be `defendpoint-async` instead
 (api/defendpoint GET "/:zoom/:x/:y/:lat-field-id/:lon-field-id/:lat-col-idx/:lon-col-idx/"
   "This endpoints provides an image with the appropriate pins rendered given a MBQL QUERY (passed as a GET query
   string param). We evaluate the query and find the set of lat/lon pairs which are relevant and then render the
@@ -135,15 +136,34 @@
         y             (Integer/parseInt y)
         lat-col-idx   (Integer/parseInt lat-col-idx)
         lon-col-idx   (Integer/parseInt lon-col-idx)
-        query         (normalize/normalize (json/parse-string query keyword))
-        updated-query (update query :query (u/rpartial query-with-inside-filter lat-field-id lon-field-id x y zoom))
-        result        (qp/process-query-and-save-execution! updated-query {:executed-by api/*current-user-id*, :context :map-tiles})
-        points        (for [row (-> result :data :rows)]
-                        [(nth row lat-col-idx) (nth row lon-col-idx)])]
+
+        query
+        (normalize/normalize (json/parse-string query keyword))
+
+        updated-query
+        (-> query
+            (update :query query-with-inside-filter lat-field-id lon-field-id x y zoom)
+            (assoc :async? false))
+
+        {:keys [status], {:keys [rows]} :data, :as result}
+        (qp/process-query-and-save-execution! updated-query
+          {:executed-by api/*current-user-id*
+           :context     :map-tiles})
+
+        ;; make sure query completed successfully, or API endpoint should return 400
+        _
+        (when-not (= status :completed)
+          (throw (ex-info (str (tru "Query failed"))
+                   ;; `result` might be a `core.async` channel or something we're not expecting
+                   (assoc (when (map? result) result) :status-code 400))))
+
+        points
+        (for [row rows]
+          [(nth row lat-col-idx) (nth row lon-col-idx)])]
     ;; manual ring response here.  we simply create an inputstream from the byte[] of our image
     {:status  200
      :headers {"Content-Type" "image/png"}
-     :body    (java.io.ByteArrayInputStream. (tile->byte-array (create-tile zoom points)))}))
+     :body    (ByteArrayInputStream. (tile->byte-array (create-tile zoom points)))}))
 
 
 (api/define-routes)
diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj
index e6206bd2426..e20cb6c77a4 100644
--- a/src/metabase/pulse.clj
+++ b/src/metabase/pulse.clj
@@ -24,22 +24,24 @@
 ;;; ------------------------------------------------- PULSE SENDING --------------------------------------------------
 
 
-;; TODO: this is probably something that could live somewhere else and just be reused
+;; TODO - this is probably something that could live somewhere else and just be reused
+;; TODO - this should be done async
 (defn execute-card
-  "Execute the query for a single card with CARD-ID. OPTIONS are passed along to `dataset-query`."
-  [card-id & {:as options}]
-  {:pre [(integer? card-id)]}
-  (try
-    (when-let [card (Card :id card-id, :archived false)]
-      (let [{:keys [creator_id dataset_query]} card]
-        {:card   card
-         :result (qp/process-query-and-save-with-max-results-constraints! dataset_query
-                   (merge {:executed-by creator_id
-                           :context     :pulse
-                           :card-id     card-id}
-                          options))}))
-    (catch Throwable t
-      (log/warn t (trs "Error running query for Card {0}" card-id)))))
+  "Execute the query for a single Card. `options` are passed along to the Query Processor."
+  [card-or-id & {:as options}]
+  (let [card-id (u/get-id card-or-id)]
+    (try
+      (when-let [card (Card :id card-id, :archived false)]
+        (let [{:keys [creator_id dataset_query]} card
+              query                              (assoc dataset_query :async? false)]
+          {:card   card
+           :result (qp/process-query-and-save-with-max-results-constraints! query
+                     (merge {:executed-by creator_id
+                             :context     :pulse
+                             :card-id     card-id}
+                            options))}))
+      (catch Throwable e
+        (log/warn e (trs "Error running query for Card {0}" card-id))))))
 
 (defn- database-id [card]
   (or (:database_id card)
diff --git a/src/metabase/query_processor/middleware/constraints.clj b/src/metabase/query_processor/middleware/constraints.clj
index 3aaf520c908..253fce9d5c9 100644
--- a/src/metabase/query_processor/middleware/constraints.clj
+++ b/src/metabase/query_processor/middleware/constraints.clj
@@ -1,4 +1,6 @@
-(ns metabase.query-processor.middleware.constraints)
+(ns metabase.query-processor.middleware.constraints
+  "Middleware that adds default constraints to limit the maximum number of rows returned to queries that specify the
+  `:add-default-userland-constraints?` `:middleware` option.")
 
 (def ^:private max-results-bare-rows
   "Maximum number of rows to return specifically on :rows type queries via the API."
@@ -13,21 +15,24 @@
   {:max-results           max-results
    :max-results-bare-rows max-results-bare-rows})
 
-(defn- merge-default-constraints [{:keys [max-results], :as constraints}]
-  (merge
-   default-query-constraints
-   ;; `:max-results-bare-rows` must be less than or equal to `:max-results`, so if someone sets `:max-results` but not
-   ;; `:max-results-bare-rows` use the same value for both. Otherwise the default bare rows value could end up being
-   ;; higher than the custom `:max-rows` value, causing an error
-   (when max-results
-     {:max-results-bare-rows max-results})
-   constraints))
+(defn- ensure-valid-constraints
+  "`:max-results-bare-rows` must be less than or equal to `:max-results`, so if someone sets `:max-results` but not
+  `:max-results-bare-rows` or sets an both but sets an invalid value for ``:max-results-bare-rows` use the same value
+  for both. Otherwise the default bare rows value could end up being higher than the custom `:max-rows` value, causing
+  an error."
+  [{:keys [max-results max-results-bare-rows], :as constraints}]
+  (if (<= max-results-bare-rows max-results)
+    constraints
+    (assoc constraints :max-results-bare-rows max-results)))
+
+(defn- merge-default-constraints [constraints]
+  (merge default-query-constraints constraints))
 
 (defn- add-default-userland-constraints*
   "Add default values of `:max-results` and `:max-results-bare-rows` to `:constraints` map `m`."
   [{{:keys [add-default-userland-constraints?]} :middleware, :as query}]
   (cond-> query
-    add-default-userland-constraints? (update :constraints merge-default-constraints)))
+    add-default-userland-constraints? (update :constraints (comp ensure-valid-constraints merge-default-constraints))))
 
 (defn add-default-userland-constraints
   "Middleware that optionally adds default `max-results` and `max-results-bare-rows` constraints to queries, meant for
diff --git a/src/metabase/server.clj b/src/metabase/server.clj
index 71c19a5442f..81b1bba0dd0 100644
--- a/src/metabase/server.clj
+++ b/src/metabase/server.clj
@@ -53,16 +53,17 @@
   creating one-off web servers for tests and REPL usage."
   ^Server [handler options]
   (doto ^Server (#'ring-jetty/create-server (assoc options :async? true))
-    (.setHandler (#'ring-jetty/async-proxy-handler
-                  handler
-                  ;; if any API endpoint functions aren't at the very least returning a channel to fetch the results
-                  ;; later after 10 minutes we're in serious trouble. (Almost everything 'slow' should be returning a
-                  ;; channel before then, but some things like CSV downloads don't currently return channels at this
-                  ;; time)
-                  ;;
-                  ;; TODO - I suppose the default value should be moved to the `metabase.config` namespace?
-                  (or (config/config-int :mb-jetty-async-response-timeout)
-                      (* 10 60 1000))))))
+    (.setHandler
+     (#'ring-jetty/async-proxy-handler
+      handler
+      ;; if any API endpoint functions aren't at the very least returning a channel to fetch the results
+      ;; later after 10 minutes we're in serious trouble. (Almost everything 'slow' should be returning a
+      ;; channel before then, but some things like CSV downloads don't currently return channels at this
+      ;; time)
+      ;;
+      ;; TODO - I suppose the default value should be moved to the `metabase.config` namespace?
+      (or (config/config-int :mb-jetty-async-response-timeout)
+          (* 10 60 1000))))))
 
 (defn start-web-server!
   "Start the embedded Jetty web server. Returns `:started` if a new server was started; `nil` if there was already a
diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj
index bfc0b23ca09..78a7fe8ac47 100644
--- a/test/metabase/api/card_test.clj
+++ b/test/metabase/api/card_test.clj
@@ -1,6 +1,7 @@
 (ns metabase.api.card-test
   "Tests for /api/card endpoints."
   (:require [cheshire.core :as json]
+            [clojure.data.csv :as csv]
             [dk.ative.docjure.spreadsheet :as spreadsheet]
             [expectations :refer :all]
             [medley.core :as m]
@@ -25,11 +26,13 @@
              [table :refer [Table]]
              [view-log :refer [ViewLog]]]
             [metabase.query-processor.async :as qp.async]
-            [metabase.query-processor.middleware.results-metadata :as results-metadata]
+            [metabase.query-processor.middleware
+             [constraints :as constraints]
+             [results-metadata :as results-metadata]]
             [metabase.test
              [data :as data]
              [util :as tu :refer [match-$ random-name]]]
-            [metabase.test.data.users :refer :all]
+            [metabase.test.data.users :as test-users :refer :all]
             [metabase.util.date :as du]
             [toucan.db :as db]
             [toucan.util.test :as tt])
@@ -1246,6 +1249,38 @@
            (spreadsheet/select-sheet "Query result")
            (spreadsheet/select-columns {:A :col})))))
 
+;; Downloading CSV/JSON/XLSX results shouldn't be subject to the default query constraints -- even if the query comes
+;; in with `add-default-userland-constraints` (as will be the case if the query gets saved from one that had it -- see
+;; #9831)
+(expect
+  101
+  (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}]
+    (tt/with-temp Card [card {:dataset_query {:database (data/id)
+                                              :type     :query
+                                              :query    {:source-table (data/id :venues)}
+                                              :middleware
+                                              {:add-default-userland-constraints? true
+                                               :userland-query?                   true}}}]
+      (with-cards-in-readable-collection card
+        (let [results ((user->client :rasta) :post 200 (format "card/%d/query/csv" (u/get-id card)))]
+          (count (csv/read-csv results)))))))
+
+;; non-"download" queries should still get the default constraints
+;; (this also is a sanitiy check to make sure the `with-redefs` in the test above actually works)
+(expect
+  10
+  (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}]
+    (tt/with-temp Card [card {:dataset_query {:database (data/id)
+                                              :type     :query
+                                              :query    {:source-table (data/id :venues)}
+                                              :middleware
+                                              {:add-default-userland-constraints? true
+                                               :userland-query?                   true}}}]
+      (with-cards-in-readable-collection card
+        (let [{row-count :row_count, :as result}
+              ((user->client :rasta) :post 200 (format "card/%d/query" (u/get-id card)))]
+          (or row-count result))))))
+
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                                  COLLECTIONS                                                   |
diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj
index dc426351eed..c25a1717e3d 100644
--- a/test/metabase/api/dataset_test.clj
+++ b/test/metabase/api/dataset_test.clj
@@ -24,18 +24,20 @@
   (:import com.fasterxml.jackson.core.JsonGenerator))
 
 (defn- format-response [m]
-  (into {} (for [[k v] (-> m
-                           (m/dissoc-in [:data :results_metadata])
-                           (m/dissoc-in [:data :insights]))]
-             (cond
-               (contains? #{:id :started_at :running_time :hash} k)
-               [k (boolean v)]
+  (into
+   {}
+   (for [[k v] (-> m
+                   (m/dissoc-in [:data :results_metadata])
+                   (m/dissoc-in [:data :insights]))]
+     (cond
+       (contains? #{:id :started_at :running_time :hash} k)
+       [k (boolean v)]
 
-               (and (= :data k) (contains? v :native_form))
-               [k (update v :native_form boolean)]
+       (and (= :data k) (contains? v :native_form))
+       [k (update v :native_form boolean)]
 
-               :else
-               [k v]))))
+       :else
+       [k v]))))
 
 (defn- most-recent-query-execution [] (db/select-one QueryExecution {:order-by [[:id :desc]]}))
 
@@ -233,3 +235,31 @@
                            :type     :query
                            :query    {:source-table (str "card__" (u/get-id card))}}))]
       (count (csv/read-csv result)))))
+
+;; POST /api/dataset/:format Downloading CSV/JSON/XLSX results shouldn't be subject to the default query constraints
+;; -- even if the query comes in with `add-default-userland-constraints` (as will be the case if the query gets saved
+;; from one that had it -- see #9831)
+(expect
+  101
+  (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}]
+    (let [result ((test-users/user->client :rasta) :post 200 "dataset/csv"
+                  :query (json/generate-string
+                          {:database   (data/id)
+                           :type       :query
+                           :query      {:source-table (data/id :venues)}
+                           :middleware
+                           {:add-default-userland-constraints? true
+                            :userland-query?                   true}}))]
+      (count (csv/read-csv result)))))
+
+;; non-"download" queries should still get the default constraints
+;; (this also is a sanitiy check to make sure the `with-redefs` in the test above actually works)
+(expect
+  10
+  (with-redefs [constraints/default-query-constraints {:max-results 10, :max-results-bare-rows 10}]
+    (let [{row-count :row_count, :as result}
+          ((test-users/user->client :rasta) :post 200 "dataset"
+           {:database (data/id)
+            :type     :query
+            :query    {:source-table (data/id :venues)}})]
+      (or row-count result))))
diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj
index 95ef7b7bcfc..1c575c4ddcf 100644
--- a/test/metabase/api/pulse_test.clj
+++ b/test/metabase/api/pulse_test.clj
@@ -1,11 +1,13 @@
 (ns metabase.api.pulse-test
   "Tests for /api/pulse endpoints."
-  (:require [expectations :refer :all]
+  (:require [expectations :refer [expect]]
             [metabase
              [email-test :as et]
              [http-client :as http]
              [util :as u]]
-            [metabase.api.card-test :as card-api-test]
+            [metabase.api
+             [card-test :as card-api-test]
+             [pulse :as pulse-api]]
             [metabase.integrations.slack :as slack]
             [metabase.middleware.util :as middleware.u]
             [metabase.models
@@ -913,6 +915,17 @@
             {:response ((user->client :rasta) :post 200 "pulse/test" (assoc result :channels [email-channel]))
              :emails   (et/regex-email-bodies #"A Pulse")}))))))
 
+;; A Card saved with `:async?` true should not be ran async for a Pulse
+(expect
+  map?
+  (#'pulse-api/pulse-card-query-results
+   {:id            1
+    :dataset_query {:database (data/id)
+                    :type     :query
+                    :query    {:source-table (data/id :venues)
+                               :limit        1}
+                    :async?   true}}))
+
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                         GET /api/pulse/form_input                                              |
diff --git a/test/metabase/api/tiles_test.clj b/test/metabase/api/tiles_test.clj
index f8f17775f11..0bce63247a0 100644
--- a/test/metabase/api/tiles_test.clj
+++ b/test/metabase/api/tiles_test.clj
@@ -1,16 +1,42 @@
 (ns metabase.api.tiles-test
   "Tests for `/api/tiles` endpoints."
   (:require [cheshire.core :as json]
-            [expectations :refer :all]
-            [metabase.test.data :as data]
-            [metabase.test.data.users :refer :all]))
+            [expectations :refer [expect]]
+            [metabase.test
+             [data :as data]
+             [util :as tu]]
+            [metabase.test.data.users :as test-users]
+            [schema.core :as s]))
 
 ;;; GET /api/tiles/:zoom/:x/:y/:lat-field-id/:lon-field-id/:lat-col-idx/:lon-col-idx/
 (expect
-  String
-  ((user->client :rasta) :get 200 (format "tiles/1/1/1/%d/%d/1/1/"
-                                          (data/id :venues :latitude)
-                                          (data/id :venues :longitude))
-   :query (json/generate-string {:database (data/id)
-                                 :type     :query
-                                 :query    {:source-table (data/id :venues)}})))
+  #".*PNG.*"
+  ((test-users/user->client :rasta) :get 200 (format "tiles/1/1/1/%d/%d/1/1/"
+                                                     (data/id :venues :latitude)
+                                                     (data/id :venues :longitude))
+   :query (json/generate-string
+           {:database (data/id)
+            :type     :query
+            :query    {:source-table (data/id :venues)}})))
+
+;; if the query fails, don't attempt to generate a map without any points -- the endpoint should return a 400
+(tu/expect-schema
+  {:status   (s/eq "failed")
+   s/Keyword s/Any}
+  ((test-users/user->client :rasta) :get 400 (format "tiles/1/1/1/%d/%d/1/1/"
+                                                     (data/id :venues :latitude)
+                                                     (data/id :venues :longitude))
+   :query "{}"))
+
+;; even if the original query was saved as `:async?` we shouldn't run the query as async (and get a core.async channel
+;; and fail)
+(expect
+  #".*PNG.*"
+  ((test-users/user->client :rasta) :get 200 (format "tiles/1/1/1/%d/%d/1/1/"
+                                                     (data/id :venues :latitude)
+                                                     (data/id :venues :longitude))
+   :query (json/generate-string
+           {:database (data/id)
+            :type     :query
+            :query    {:source-table (data/id :venues)}
+            :async?   true})))
diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj
index 965279850e3..ecd6fbddfae 100644
--- a/test/metabase/pulse_test.clj
+++ b/test/metabase/pulse_test.clj
@@ -6,11 +6,11 @@
             [medley.core :as m]
             [metabase
              [email-test :as et]
-             [pulse :refer :all]]
+             [pulse :as pulse]]
             [metabase.integrations.slack :as slack]
             [metabase.models
              [card :refer [Card]]
-             [pulse :refer [Pulse retrieve-notification retrieve-pulse]]
+             [pulse :as pulse.model :refer [Pulse]]
              [pulse-card :refer [PulseCard]]
              [pulse-channel :refer [PulseChannel]]
              [pulse-channel-recipient :refer [PulseChannelRecipient]]]
@@ -22,6 +22,7 @@
             [metabase.test.data
              [dataset-definitions :as defs]
              [users :as users]]
+            [schema.core :as s]
             [toucan.db :as db]
             [toucan.util.test :as tt]))
 
@@ -101,7 +102,7 @@
                   PulseChannelRecipient [_             {:user_id (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Basic test, 1 card, 1 recipient, 21 results results in a CSV being attached and a table being sent
@@ -121,7 +122,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"  #"More results have been included" #"ID</th>"))))
 
 ;; Validate pulse queries are limited by `default-query-constraints`
@@ -139,7 +140,7 @@
     (email-test-setup
      (with-redefs [constraints/default-query-constraints {:max-results           10000
                                                           :max-results-bare-rows 30}]
-       (send-pulse! (retrieve-pulse pulse-id))
+       (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
        ;; Slurp in the generated CSV and count the lines found in the file
        (-> @et/inbox
            vals
@@ -167,7 +168,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"  #"More results have been included" #"ID</th>"))))
 
 ;; Pulse should be sent to two recipients
@@ -190,7 +191,7 @@
                   PulseChannelRecipient [_             {:user_id (users/user->id :crowberto)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; 1 pulse that has 2 cards, should contain two attachments
@@ -212,7 +213,7 @@
                   PulseChannelRecipient [_             {:user_id (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Pulse where the card has no results, but skip_if_empty is false, so should still send
@@ -229,7 +230,7 @@
                   PulseChannelRecipient [_              {:user_id          (rasta-id)
                                                          :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Pulse where the card has no results, skip_if_empty is true, so no pulse should be sent
@@ -246,7 +247,7 @@
                   PulseChannelRecipient [_              {:user_id          (rasta-id)
                                                          :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      @et/inbox)))
 
 ;; Rows alert with no data
@@ -263,7 +264,7 @@
                   PulseChannelRecipient [_              {:user_id          (rasta-id)
                                                          :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      @et/inbox)))
 
 (defn- rasta-alert-email
@@ -288,7 +289,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex #"More results have been included"))))
 
 ;; Rows alert with too much data will attach as CSV and include a table
@@ -309,7 +310,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex #"More results have been included" #"ID</th>"))))
 
 ;; Above goal alert with data
@@ -330,7 +331,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 ;; Native query with user-specified x and y axis
@@ -358,7 +359,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 ;; Above goal alert, with no data above goal
@@ -378,7 +379,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      @et/inbox)))
 
 ;; Below goal alert with no satisfying data
@@ -398,7 +399,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      @et/inbox)))
 
 ;; Below goal alert with data
@@ -420,7 +421,7 @@
                                                         :pulse_channel_id pc-id}]]
 
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 (defn- thunk->boolean [{:keys [attachments] :as result}]
@@ -483,7 +484,7 @@
      :channel-id "FOO",
      :fallback card-name}]}
   (slack-test-setup
-   (-> (send-pulse! (retrieve-pulse pulse-id))
+   (-> (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
        first
        thunk->boolean)))
 
@@ -521,7 +522,7 @@
    ]
   (slack-test-setup
    (with-redefs [render/attached-results-text (wrap-function (var-get #'render/attached-results-text))]
-     (let [[pulse-results] (send-pulse! (retrieve-pulse pulse-id))]
+     (let [[pulse-results] (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))]
        ;; If we don't force the thunk, the rendering code will never execute and attached-results-text won't be called
        (force-bytes-thunk pulse-results)
        [(thunk->boolean pulse-results)
@@ -564,7 +565,7 @@
       :fallback "Test card 2"}]}
    true]
   (slack-test-setup
-   (let [[slack-data] (send-pulse! (retrieve-pulse pulse-id))]
+   (let [[slack-data] (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))]
      [(thunk->boolean slack-data)
       (every? produces-bytes? (:attachments slack-data))])))
 
@@ -607,7 +608,7 @@
    true
    true]
   (slack-test-setup
-   (let [pulse-data (send-pulse! (retrieve-pulse pulse-id))
+   (let [pulse-data (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
          slack-data (m/find-first #(contains? % :channel-id) pulse-data)
          email-data (m/find-first #(contains? % :subject) pulse-data)]
      [(thunk->boolean slack-data)
@@ -635,7 +636,7 @@
                    :fallback card-name}]}
    true]
   (slack-test-setup
-   (let [[result] (send-pulse! (retrieve-notification pulse-id))]
+   (let [[result] (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))]
      [(thunk->boolean result)
       (every? produces-bytes? (:attachments result))])))
 
@@ -663,7 +664,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 ;; Below goal alert with progress bar
@@ -683,7 +684,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 ;; Rows alert, first run only with data
@@ -701,7 +702,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex
                                    #"stop sending you alerts"))))
 
@@ -719,7 +720,7 @@
                   PulseChannelRecipient [_              {:user_id          (rasta-id)
                                                          :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      [@et/inbox
       (db/exists? Pulse :id pulse-id)])))
 
@@ -743,7 +744,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Basic alert test, 1 card, 1 recipient, with CSV attachment
@@ -761,7 +762,7 @@
                   PulseChannelRecipient [_              {:user_id          (rasta-id)
                                                          :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
 
 ;; With a "rows" type of pulse (table visualization) we should include the CSV by default
@@ -784,7 +785,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; If the pulse is already configured to send an XLS, no need to include a CSV
@@ -808,7 +809,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Basic test of card with CSV and XLS attachments, but no data. Should not include an attachment
@@ -828,7 +829,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Basic test, 1 card, 1 recipient, with XLS attachment
@@ -846,7 +847,7 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-pulse pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-pulse pulse-id))
      (et/summarize-multipart-email #"Pulse Name"))))
 
 ;; Rows alert with data and a CSV + XLS attachment
@@ -865,5 +866,15 @@
                   PulseChannelRecipient [_             {:user_id          (rasta-id)
                                                         :pulse_channel_id pc-id}]]
     (email-test-setup
-     (send-pulse! (retrieve-notification pulse-id))
+     (pulse/send-pulse! (pulse.model/retrieve-notification pulse-id))
      (et/summarize-multipart-email test-card-regex))))
+
+;; even if Card is saved as `:async?` we shouldn't run the query async
+(tu/expect-schema
+  {:card   (s/pred map?)
+   :result (s/pred map?)}
+  (tt/with-temp Card [card {:dataset_query {:database (data/id)
+                                            :type     :query
+                                            :query    {:source-table (data/id :venues)}
+                                            :async?   true}}]
+    (pulse/execute-card card)))
diff --git a/test/metabase/query_processor/middleware/constraints_test.clj b/test/metabase/query_processor/middleware/constraints_test.clj
new file mode 100644
index 00000000000..5d463368857
--- /dev/null
+++ b/test/metabase/query_processor/middleware/constraints_test.clj
@@ -0,0 +1,58 @@
+(ns metabase.query-processor.middleware.constraints-test
+  (:require [expectations :refer [expect]]
+            [metabase.query-processor.middleware.constraints :as constraints]))
+
+(defn- add-default-userland-constraints [query]
+  ((constraints/add-default-userland-constraints
+     (fn [query respond _ _]
+       (respond query)))
+   query
+   identity
+   identity
+   nil))
+
+;; don't do anything to queries without [:middleware :add-default-userland-constraints?] set
+(expect
+  {}
+  (add-default-userland-constraints {}))
+
+;; if it is *truthy* add the constraints
+(expect
+  {:middleware  {:add-default-userland-constraints? true},
+   :constraints {:max-results           @#'constraints/max-results
+                 :max-results-bare-rows @#'constraints/max-results-bare-rows}}
+  (add-default-userland-constraints
+   {:middleware {:add-default-userland-constraints? true}}))
+
+;; don't do anything if it's not truthy
+(expect
+  {:middleware {:add-default-userland-constraints? false}}
+  (add-default-userland-constraints
+   {:middleware {:add-default-userland-constraints? false}}))
+
+;; if it already has constraints, don't overwrite those!
+(expect
+  {:middleware  {:add-default-userland-constraints? true}
+   :constraints {:max-results           @#'constraints/max-results
+                 :max-results-bare-rows 1}}
+  (add-default-userland-constraints
+   {:constraints {:max-results-bare-rows 1}
+    :middleware  {:add-default-userland-constraints? true}}))
+
+;; if you specify just `:max-results` it should make sure `:max-results-bare-rows` is <= `:max-results`
+(expect
+  {:middleware  {:add-default-userland-constraints? true}
+   :constraints {:max-results           5
+                 :max-results-bare-rows 5}}
+  (add-default-userland-constraints
+   {:constraints {:max-results 5}
+    :middleware  {:add-default-userland-constraints? true}}))
+
+;; if you specify both it should still make sure `:max-results-bare-rows` is <= `:max-results`
+(expect
+  {:middleware  {:add-default-userland-constraints? true}
+   :constraints {:max-results           5
+                 :max-results-bare-rows 5}}
+  (add-default-userland-constraints
+   {:constraints {:max-results 5, :max-results-bare-rows 10}
+    :middleware  {:add-default-userland-constraints? true}}))
diff --git a/test/metabase/task/sync_databases_test.clj b/test/metabase/task/sync_databases_test.clj
index b5ae6a245ff..46cce1f9384 100644
--- a/test/metabase/task/sync_databases_test.clj
+++ b/test/metabase/task/sync_databases_test.clj
@@ -151,19 +151,17 @@
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
 (defn- check-if-sync-processes-ran-for-db {:style/indent 0} [db-info]
-  (let [sync-db-metadata-counter    (atom 0)
-        analyze-db-counter          (atom 0)
-        update-field-values-counter (atom 0)]
-    (with-redefs [metabase.sync.sync-metadata/sync-db-metadata!   (fn [& _] (swap! sync-db-metadata-counter inc))
-                  metabase.sync.analyze/analyze-db!               (fn [& _] (swap! analyze-db-counter inc))
-                  metabase.sync.field-values/update-field-values! (fn [& _] (swap! update-field-values-counter inc))]
+  (let [sync-db-metadata-ran?    (promise)
+        analyze-db-ran?          (promise)
+        update-field-values-ran? (promise)]
+    (with-redefs [metabase.sync.sync-metadata/sync-db-metadata!   (fn [& _] (deliver sync-db-metadata-ran? true))
+                  metabase.sync.analyze/analyze-db!               (fn [& _] (deliver analyze-db-ran? true))
+                  metabase.sync.field-values/update-field-values! (fn [& _] (deliver update-field-values-ran? true))]
       (with-scheduler-setup
         (tt/with-temp Database [database db-info]
-          ;; give tasks some time to run
-          (Thread/sleep 2000)
-          {:ran-sync?                (not (zero? @sync-db-metadata-counter))
-           :ran-analyze?             (not (zero? @analyze-db-counter))
-           :ran-update-field-values? (not (zero? @update-field-values-counter))})))))
+          {:ran-sync?                (deref sync-db-metadata-ran? 500 false)
+           :ran-analyze?             (deref analyze-db-ran? 100 false)
+           :ran-update-field-values? (deref update-field-values-ran? 500 false)})))))
 
 (defn- cron-schedule-for-next-year []
   (format "0 15 10 * * ? %d" (inc (du/date-extract :year))))
-- 
GitLab