diff --git a/bin/docker/build_image.sh b/bin/docker/build_image.sh index 25007e8b6e5bad134a61c8018fe1a6faeb0c5a3a..de15c927e78ad4ecf42893301f42e596b50d2dc4 100755 --- a/bin/docker/build_image.sh +++ b/bin/docker/build_image.sh @@ -43,7 +43,7 @@ if [ "$BUILD_TYPE" == "release" ]; then echo "Building Docker image ${DOCKER_IMAGE} from official Metabase release ${MB_TAG}" # download the official version of Metabase which matches our tag - curl -f -o ${BASEDIR}/metabase.jar http://downloads.metabase.com/${MB_TAG}/metabase.jar + curl -L -f -o ${BASEDIR}/metabase.jar http://downloads.metabase.com/${MB_TAG}/metabase.jar if [[ $? -ne 0 ]]; then echo "Download failed!" diff --git a/frontend/src/metabase/auth/auth.js b/frontend/src/metabase/auth/auth.js index 1855ac242132ae766e8f4d9089d42c2ec181a24e..d293aa657a112b789b8c44d6c7ae82985f3eb914 100644 --- a/frontend/src/metabase/auth/auth.js +++ b/frontend/src/metabase/auth/auth.js @@ -6,6 +6,7 @@ import { import { push } from "react-router-redux"; +import MetabaseCookies from "metabase/lib/cookies"; import MetabaseUtils from "metabase/lib/utils"; import MetabaseAnalytics from "metabase/lib/analytics"; import MetabaseSettings from "metabase/lib/settings"; @@ -35,8 +36,10 @@ export const login = createThunkAction(LOGIN, function( } try { - // NOTE: this request will return a Set-Cookie header for the session - await SessionApi.create(credentials); + let newSession = await SessionApi.create(credentials); + + // since we succeeded, lets set the session cookie + MetabaseCookies.setSessionCookie(newSession.id); MetabaseAnalytics.trackEvent("Auth", "Login"); // TODO: redirect after login (carry user to intended destination) @@ -56,11 +59,13 @@ export const loginGoogle = createThunkAction(LOGIN_GOOGLE, function( ) { return async function(dispatch, getState) { try { - // NOTE: this request will return a Set-Cookie header for the session - await SessionApi.createWithGoogleAuth({ + let newSession = await SessionApi.createWithGoogleAuth({ token: googleUser.getAuthResponse().id_token, }); + // since we succeeded, lets set the session cookie + MetabaseCookies.setSessionCookie(newSession.id); + MetabaseAnalytics.trackEvent("Auth", "Google Auth Login"); // TODO: redirect after login (carry user to intended destination) @@ -82,12 +87,13 @@ export const loginGoogle = createThunkAction(LOGIN_GOOGLE, function( export const LOGOUT = "metabase/auth/LOGOUT"; export const logout = createThunkAction(LOGOUT, function() { return function(dispatch, getState) { - // actively delete the session and remove the cookie - SessionApi.delete(); - - // clear Google auth credentials if any are present - clearGoogleAuthCredentials(); + // TODO: as part of a logout we want to clear out any saved state that we have about anything + let sessionId = MetabaseCookies.setSessionCookie(); + if (sessionId) { + // actively delete the session + SessionApi.delete({ session_id: sessionId }); + } MetabaseAnalytics.trackEvent("Auth", "Logout"); dispatch(push("/auth/login")); @@ -112,12 +118,16 @@ export const passwordReset = createThunkAction(PASSWORD_RESET, function( } try { - // NOTE: this request will return a Set-Cookie header for the session - await SessionApi.reset_password({ + let result = await SessionApi.reset_password({ token: token, password: credentials.password, }); + if (result.session_id) { + // we should have a valid session that we can use immediately! + MetabaseCookies.setSessionCookie(result.session_id); + } + MetabaseAnalytics.trackEvent("Auth", "Password Reset"); return { diff --git a/frontend/src/metabase/setup/actions.js b/frontend/src/metabase/setup/actions.js index 8c286ba6ffb6f09e6cc2ca8c94c7c5c3c860bd06..e8c5e527da2d2db0a74b421029288f2558bfd11a 100644 --- a/frontend/src/metabase/setup/actions.js +++ b/frontend/src/metabase/setup/actions.js @@ -1,7 +1,9 @@ +//import _ from "underscore"; import { createAction } from "redux-actions"; import { createThunkAction } from "metabase/lib/redux"; import MetabaseAnalytics from "metabase/lib/analytics"; +import MetabaseCookies from "metabase/lib/cookies"; import MetabaseSettings from "metabase/lib/settings"; import { SetupApi, UtilApi } from "metabase/services"; @@ -48,7 +50,6 @@ export const submitSetup = createThunkAction(SUBMIT_SETUP, function() { let { setup: { allowTracking, databaseDetails, userDetails } } = getState(); try { - // NOTE: this request will return a Set-Cookie header for the session let response = await SetupApi.create({ token: MetabaseSettings.get("setup_token"), prefs: { @@ -74,6 +75,9 @@ export const submitSetup = createThunkAction(SUBMIT_SETUP, function() { export const completeSetup = createAction(COMPLETE_SETUP, function( apiResponse, ) { + // setup user session + MetabaseCookies.setSessionCookie(apiResponse.id); + // clear setup token from settings MetabaseSettings.setAll({ setup_token: null }); diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index 3b27ad0ee0c7c9ec1abcc6cd5f435bd36999d16e..ecc1829f2f37f29c6b523bff6c193f65646f58fe 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -13,7 +13,6 @@ [metabase.api.common :as api] [metabase.email.messages :as email] [metabase.integrations.ldap :as ldap] - [metabase.middleware.session :as mw.session] [metabase.models [session :refer [Session]] [setting :refer [defsetting]] @@ -24,21 +23,19 @@ [schema :as su]] [schema.core :as s] [throttle.core :as throttle] - [toucan.db :as db]) - (:import com.unboundid.util.LDAPSDKException - java.util.UUID)) + [toucan.db :as db])) -(s/defn ^:private create-session! :- UUID +(defn- create-session! "Generate a new `Session` for a given `User`. Returns the newly generated session ID." - [user :- {:id su/IntGreaterThanZero - :last_login s/Any - s/Keyword s/Any}] - (u/prog1 (UUID/randomUUID) + [user] + {:pre [(map? user) (integer? (:id user)) (contains? user :last_login)] + :post [(string? %)]} + (u/prog1 (str (java.util.UUID/randomUUID)) (db/insert! Session - :id (str <>) + :id <> :user_id (:id user)) (events/publish-event! :user-login - {:user_id (:id user), :session_id (str <>), :first_login (nil? (:last_login user))}))) + {:user_id (:id user), :session_id <>, :first_login (not (boolean (:last_login user)))}))) ;;; ## API Endpoints @@ -50,7 +47,7 @@ (def ^:private password-fail-message (tru "Password did not match stored password.")) (def ^:private password-fail-snippet (tru "did not match stored password")) -(s/defn ^:private ldap-login :- (s/maybe UUID) +(defn- ldap-login "If LDAP is enabled and a matching user exists return a new Session for them, or `nil` if they couldn't be authenticated." [username password] @@ -64,16 +61,18 @@ {:status-code 400 :errors {:password password-fail-snippet}}))) ;; password is ok, return new session - (create-session! (ldap/fetch-or-create-user! user-info password))) - (catch LDAPSDKException e - (log/error e (trs "Problem connecting to LDAP server, will fall back to local authentication")))))) + {:id (create-session! (ldap/fetch-or-create-user! user-info password))}) + (catch com.unboundid.util.LDAPSDKException e + (log/error + (u/format-color 'red + (trs "Problem connecting to LDAP server, will fall back to local authentication: {0}" (.getMessage e)))))))) -(s/defn ^:private email-login :- (s/maybe UUID) +(defn- email-login "Find a matching `User` if one exists and return a new Session for them, or `nil` if they couldn't be authenticated." [username password] (when-let [user (db/select-one [User :id :password_salt :password :last_login], :email username, :is_active true)] (when (pass/verify-password password (:password_salt user) (:password user)) - (create-session! user)))) + {:id (create-session! user)}))) (def ^:private throttling-disabled? (config/config-bool :mb-disable-session-throttle)) @@ -83,20 +82,6 @@ (when-not throttling-disabled? (throttle/check throttler throttle-key))) -(s/defn ^:private login :- UUID - "Attempt to login with different avaialable methods with `username` and `password`, returning new Session ID or - throwing an Exception if login could not be completed." - [username :- su/NonBlankString, password :- su/NonBlankString] - ;; Primitive "strategy implementation", should be reworked for modular providers in #3210 - (or (ldap-login username password) ; First try LDAP if it's enabled - (email-login username password) ; Then try local authentication - ;; If nothing succeeded complain about it - ;; Don't leak whether the account doesn't exist or the password was incorrect - (throw - (ui18n/ex-info password-fail-message - {:status-code 400 - :errors {:password password-fail-snippet}})))) - (api/defendpoint POST "/" "Login." [:as {{:keys [username password]} :body, remote-address :remote-addr}] @@ -104,17 +89,23 @@ password su/NonBlankString} (throttle-check (login-throttlers :ip-address) remote-address) (throttle-check (login-throttlers :username) username) - (let [session-id (login username password) - response {:id session-id}] - (mw.session/set-session-cookie response session-id))) + ;; Primitive "strategy implementation", should be reworked for modular providers in #3210 + (or (ldap-login username password) ; First try LDAP if it's enabled + (email-login username password) ; Then try local authentication + ;; If nothing succeeded complain about it + ;; Don't leak whether the account doesn't exist or the password was incorrect + (throw (ui18n/ex-info password-fail-message + {:status-code 400 + :errors {:password password-fail-snippet}})))) (api/defendpoint DELETE "/" "Logout." - [:as {:keys [metabase-session-id]}] - (api/check-exists? Session metabase-session-id) - (db/delete! Session :id metabase-session-id) - (mw.session/clear-session-cookie api/generic-204-no-content)) + [session_id] + {session_id su/NonBlankString} + (api/check-exists? Session session_id) + (db/delete! Session :id session_id) + api/generic-204-no-content) ;; Reset tokens: We need some way to match a plaintext token with the a user since the token stored in the DB is ;; hashed. So we'll make the plaintext token in the format USER-ID_RANDOM-UUID, e.g. @@ -174,11 +165,8 @@ (when-not (:last_login user) (email/send-user-joined-admin-notification-email! (User user-id))) ;; after a successful password update go ahead and offer the client a new session that they can use - (let [session-id (create-session! user)] - (mw.session/set-session-cookie - {:success true - :session_id (str session-id)} - session-id))) + {:success true + :session_id (create-session! user)}) (api/throw-invalid-param-exception :password (tru "Invalid reset token")))) @@ -243,13 +231,12 @@ ;; things hairy and only enforce those for non-Google Auth users (user/create-new-google-auth-user! new-user)) -(s/defn ^:private google-auth-fetch-or-create-user! :- (s/maybe UUID) - [first-name last-name email] - (when-let [user (or (db/select-one [User :id :last_login] :email email) - (google-auth-create-new-user! {:first_name first-name - :last_name last-name - :email email}))] - (create-session! user))) +(defn- google-auth-fetch-or-create-user! [first-name last-name email] + (if-let [user (or (db/select-one [User :id :last_login] :email email) + (google-auth-create-new-user! {:first_name first-name + :last_name last-name + :email email}))] + {:id (create-session! user)})) (api/defendpoint POST "/google_auth" "Login with Google Auth." @@ -259,9 +246,7 @@ ;; Verify the token is valid with Google (let [{:keys [given_name family_name email]} (google-auth-token-info token)] (log/info (trs "Successfully authenticated Google Auth token for: {0} {1}" given_name family_name)) - (let [session-id (api/check-500 (google-auth-fetch-or-create-user! given_name family_name email)) - response {:id session-id}] - (mw.session/set-session-cookie response session-id)))) + (google-auth-fetch-or-create-user! given_name family_name email))) (api/define-routes) diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index 34db8770dfb673b615de27f0f434e6026c971562..bdb50027f84be58db833a36e47d6ebf3f5022475 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -10,7 +10,6 @@ [common :as api] [database :as database-api :refer [DBEngineString]]] [metabase.integrations.slack :as slack] - [metabase.middleware.session :as mw.session] [metabase.models [database :refer [Database]] [session :refer [Session]] @@ -19,8 +18,7 @@ [i18n :refer [tru]] [schema :as su]] [schema.core :as s] - [toucan.db :as db]) - (:import java.util.UUID)) + [toucan.db :as db])) (def ^:private SetupToken "Schema for a string that matches the instance setup token." @@ -44,12 +42,12 @@ allow_tracking (s/maybe (s/cond-pre s/Bool su/BooleanString)) schedules (s/maybe database-api/ExpandedSchedulesMap)} ;; Now create the user - (let [session-id (UUID/randomUUID) + (let [session-id (str (java.util.UUID/randomUUID)) new-user (db/insert! User :email email :first_name first_name :last_name last_name - :password (str (UUID/randomUUID)) + :password (str (java.util.UUID/randomUUID)) :is_superuser true)] ;; this results in a second db call, but it avoids redundant password code so figure it's worth it (user/set-password! (:id new-user) password) @@ -77,12 +75,12 @@ (setup/clear-token!) ;; then we create a session right away because we want our new user logged in to continue the setup process (db/insert! Session - :id (str session-id) + :id session-id :user_id (:id new-user)) ;; notify that we've got a new user in the system AND that this user logged in (events/publish-event! :user-create {:user_id (:id new-user)}) - (events/publish-event! :user-login {:user_id (:id new-user), :session_id (str session-id), :first_login true}) - (mw.session/set-session-cookie {:id (str session-id)} session-id))) + (events/publish-event! :user-login {:user_id (:id new-user), :session_id session-id, :first_login true}) + {:id session-id})) (api/defendpoint POST "/validate" diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index d9816989b98fcdad74583f324e0e6728c300d4ee..8285ce70bf03537f1ceb59d244984d84b345103d 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -237,8 +237,7 @@ (let [reset-token (user/set-password-reset-token! id) ;; NOTE: the new user join url is just a password reset with an indicator that this is a first time user join-url (str (user/form-password-reset-url reset-token) "#new")] - (email/send-new-user-email! user @api/*current-user* join-url))) - {:success true}) + (email/send-new-user-email! user @api/*current-user* join-url)))) (api/define-routes) diff --git a/src/metabase/middleware/session.clj b/src/metabase/middleware/session.clj index ecb64c7b7e731674106d1493bf6a25100d2a07b4..f35f9cac0dcf17a8689e81cda642a3f8dee6b071 100644 --- a/src/metabase/middleware/session.clj +++ b/src/metabase/middleware/session.clj @@ -2,53 +2,22 @@ "Ring middleware related to session (binding current user and permissions)." (:require [metabase [config :as config] - [db :as mdb] - [public-settings :as public-settings]] + [db :as mdb]] [metabase.api.common :refer [*current-user* *current-user-id* *current-user-permissions-set* *is-superuser?*]] [metabase.core.initialization-status :as init-status] [metabase.models [session :refer [Session]] [user :as user :refer [User]]] - [ring.util.response :as resp] - [schema.core :as s] - [toucan.db :as db]) - (:import java.net.URL - java.util.UUID - org.joda.time.DateTime)) - -(def ^:private ^String metabase-session-cookie "metabase.SESSION_ID") -(def ^:private ^String metabase-session-header "x-metabase-session") - -(s/defn set-session-cookie - "Add a `Set-Cookie` header to `response` to persist the Metabase session." - [response, session-id :- UUID] - (if-not (and (map? response) (:body response)) - (recur {:body response, :status 200} session-id) - (resp/set-cookie - response - metabase-session-cookie - (str session-id) - (merge - {:same-site :lax - :http-only true - :path "/api" - :max-age (config/config-int :max-session-age)} - ;; If Metabase is running over HTTPS (hopefully always except for local dev instances) then make sure to make this - ;; cookie HTTPS-only - (when (some-> (public-settings/site-url) URL. .getProtocol (= "https")) - {:secure true}))))) - -(defn clear-session-cookie - "Add a header to `response` to clear the current Metabase session cookie." - [response] - (resp/set-cookie response metabase-session-cookie nil {:expires (DateTime. 0)})) + [toucan.db :as db])) + +(def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID") +(def ^:private ^:const ^String metabase-session-header "x-metabase-session") (defn- wrap-session-id* [{:keys [cookies headers] :as request}] - (let [session-id (or (get-in cookies [metabase-session-cookie :value]) - (headers metabase-session-header))] - (if (seq session-id) - (assoc request :metabase-session-id session-id) - request))) + (if-let [session-id (or (get-in cookies [metabase-session-cookie :value]) + (headers metabase-session-header))] + (assoc request :metabase-session-id session-id) + request)) (defn wrap-session-id "Middleware that sets the `:metabase-session-id` keyword on the request if a session id can be found. diff --git a/test/expectation_options.clj b/test/expectation_options.clj index 635fa87d7c302b8ce38fcdbcd79a0eaf48e4ba4b..f9dbc19aa97febe1b371e6ae586e0614ec33eec4 100644 --- a/test/expectation_options.clj +++ b/test/expectation_options.clj @@ -1,53 +1,5 @@ (ns expectation-options - "Namespace expectations will automatically load before running a tests" - (:require [clojure - [data :as data] - [set :as set]] - [expectations :as expectations] - [metabase.util :as u])) - -;;; ---------------------------------------- Expectations Framework Settings ----------------------------------------- - -;; ## EXPECTATIONS FORMATTING OVERRIDES - -;; These overrides the methods Expectations usually uses for printing failed tests. -;; These are basically the same as the original implementations, but they colorize and pretty-print the -;; output, which makes it an order of magnitude easier to read, especially for tests that compare a -;; lot of data, like Query Processor or API tests. -(defn- format-failure [e a str-e str-a] - {:type :fail - :expected-message (when-let [in-e (first (data/diff e a))] - (format "\nin expected, not actual:\n%s" (u/pprint-to-str 'green in-e))) - :actual-message (when-let [in-a (first (data/diff a e))] - (format "\nin actual, not expected:\n%s" (u/pprint-to-str 'red in-a))) - :raw [str-e str-a] - :result ["\nexpected:\n" - (u/pprint-to-str 'green e) - "\nwas:\n" - (u/pprint-to-str 'red a)]}) - -(defmethod expectations/compare-expr :expectations/maps [e a str-e str-a] - (let [[in-e in-a] (data/diff e a)] - (if (and (nil? in-e) (nil? in-a)) - {:type :pass} - (format-failure e a str-e str-a)))) - -(defmethod expectations/compare-expr :expectations/sets [e a str-e str-a] - (format-failure e a str-e str-a)) - -(defmethod expectations/compare-expr :expectations/sequentials [e a str-e str-a] - (let [diff-fn (fn [e a] (seq (set/difference (set e) (set a))))] - (assoc (format-failure e a str-e str-a) - :message (cond - (and (= (set e) (set a)) - (= (count e) (count a)) - (= (count e) (count (set a)))) "lists appear to contain the same items with different ordering" - (and (= (set e) (set a)) - (< (count e) (count a))) "some duplicate items in actual are not expected" - (and (= (set e) (set a)) - (> (count e) (count a))) "some duplicate items in expected are not actual" - (< (count e) (count a)) "actual is larger than expected" - (> (count e) (count a)) "expected is larger than actual")))) + "Namespace expectations will automatically load before running a tests") ;;; ---------------------------------------------- check-for-slow-tests ---------------------------------------------- diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index acf23547c4d115f22d91b63c723659d870f32249..e46dbce332db07230d68b2032d6cf932cf7d4260 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -13,12 +13,11 @@ [metabase.test [data :refer :all] [util :as tu]] - [metabase.test.data.users :as test-users :refer :all] + [metabase.test.data.users :refer :all] [metabase.test.integrations.ldap :refer [expect-with-ldap-server]] [metabase.test.util.log :as tu.log] [toucan.db :as db] - [toucan.util.test :as tt]) - (:import java.util.UUID)) + [toucan.util.test :as tt])) ;; ## POST /api/session ;; Test that we can login @@ -62,16 +61,10 @@ ;; 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 - ((user->client :rasta) :delete 204 "session") - ;; check whether it's still there -- should be GONE - (Session session-id)))) + (let [{session_id :id} ((user->client :rasta) :post 200 "session" (user->credentials :rasta))] + (assert session_id) + ((user->client :rasta) :delete 204 "session" :session_id session_id) + (Session session_id))) ;; ## POST /api/session/forgot_password @@ -241,15 +234,18 @@ [:first_name :last_name :email])))) -;;; --------------------------------------- google-auth-fetch-or-create-user! ---------------------------------------- +;;; tests for google-auth-fetch-or-create-user! + +(defn- is-session? [session] + (u/ignore-exceptions + (tu/is-uuid-string? (:id session)))) ;; test that an existing user can log in with Google auth even if the auto-create accounts domain is different from ;; their account should return a Session (expect - UUID (tt/with-temp User [user {:email "cam@sf-toucannery.com"}] (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "metabase.com"] - (#'session-api/google-auth-fetch-or-create-user! "Cam" "Saul" "cam@sf-toucannery.com")))) + (is-session? (#'session-api/google-auth-fetch-or-create-user! "Cam" "Saül" "cam@sf-toucannery.com"))))) ;; test that a user that doesn't exist with a *different* domain than the auto-create accounts domain gets an ;; exception @@ -262,14 +258,11 @@ ;; test that a user that doesn't exist with the *same* domain as the auto-create accounts domain means a new user gets ;; created (expect - UUID (et/with-fake-inbox (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" admin-email "rasta@toucans.com"] - (try - (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") - (finally - (db/delete! User :email "rasta@sf-toucannery.com")))))) ; clean up after ourselves + (u/prog1 (is-session? (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) + (db/delete! User :email "rasta@sf-toucannery.com"))))) ; clean up after ourselves ;;; ------------------------------------------- TESTS FOR LDAP AUTH STUFF -------------------------------------------- diff --git a/test/metabase/http_client.clj b/test/metabase/http_client.clj index 6683792441936c70b6c7d122be1aa16ca3890749..09b79f6df0a1d1323e2c177549238f47f92d47ab 100644 --- a/test/metabase/http_client.clj +++ b/test/metabase/http_client.clj @@ -2,14 +2,12 @@ "HTTP client for making API calls against the Metabase API. For test/REPL purposes." (:require [cheshire.core :as json] [clj-http.client :as client] - [clojure.string :as str] + [clojure.string :as s] [clojure.tools.logging :as log] [metabase [config :as config] [util :as u]] - [metabase.middleware.session :as mw.session] - [metabase.util.date :as du] - [schema.core :as s])) + [metabase.util.date :as du])) ;;; build-url @@ -24,7 +22,7 @@ [url url-param-kwargs] {:pre [(string? url) (u/maybe? map? url-param-kwargs)]} (str *url-prefix* url (when (seq url-param-kwargs) - (str "?" (str/join \& (for [[k v] url-param-kwargs] + (str "?" (s/join \& (for [[k v] url-param-kwargs] (str (if (keyword? k) (name k) k) \= (if (keyword? v) (name v) v)))))))) @@ -58,7 +56,7 @@ (try (auto-deserialize-dates (json/parse-string body keyword)) (catch Throwable _ - (when-not (str/blank? body) + (when-not (s/blank? body) body))))) @@ -66,14 +64,15 @@ (declare client) -(s/defn authenticate +(defn authenticate "Authenticate a test user with USERNAME and PASSWORD, returning their Metabase Session token; or throw an Exception if that fails." - [credentials :- {:username s/Str, :password s/Str}] + [{:keys [username password], :as credentials}] + {:pre [(string? username) (string? password)]} (try (:id (client :post 200 "session" credentials)) (catch Throwable e - (println "Failed to authenticate with credentials" credentials e)))) + (println "Failed to authenticate with username:" username "and password:" password ":" (.getMessage e))))) ;;; client @@ -81,11 +80,10 @@ (defn- build-request-map [credentials http-body] (merge {:accept :json - :headers {@#'mw.session/metabase-session-header - (when credentials - (if (map? credentials) - (authenticate credentials) - credentials))} + :headers {"X-METABASE-SESSION" (when credentials + (if (map? credentials) + (authenticate credentials) + credentials))} :content-type :json} (when (seq http-body) {:body (json/generate-string http-body)}))) @@ -121,7 +119,7 @@ (let [request-map (merge (build-request-map credentials http-body) request-options) request-fn (method->request-fn method) url (build-url url url-param-kwargs) - method-name (str/upper-case (name method)) + method-name (s/upper-case (name method)) ;; Now perform the HTTP request {:keys [status body] :as resp} (try (request-fn url request-map) (catch clojure.lang.ExceptionInfo e diff --git a/test/metabase/test/data/users.clj b/test/metabase/test/data/users.clj index a714ccf73ba52d20d4d0f0eca21b1e9fb664822b..edfa9d35e6e7c34bf5a76cf6a6e8a77d51802d38 100644 --- a/test/metabase/test/data/users.clj +++ b/test/metabase/test/data/users.clj @@ -8,10 +8,8 @@ [metabase.core.initialization-status :as init-status] [metabase.middleware.session :as mw.session] [metabase.models.user :as user :refer [User]] - [schema.core :as s] [toucan.db :as db]) - (:import clojure.lang.ExceptionInfo - metabase.models.user.UserInstance)) + (:import clojure.lang.ExceptionInfo)) ;;; ------------------------------------------------ User Definitions ------------------------------------------------ @@ -44,12 +42,9 @@ :password "birdseed" :active false}}) -(def ^:private usernames +(def ^:private ^:const usernames (set (keys user->info))) -(def ^:private TestUserName - (apply s/enum usernames)) - ;;; ------------------------------------------------- Test User Fns -------------------------------------------------- (defn- wait-for-initiailization @@ -72,8 +67,8 @@ (defn- fetch-or-create-user! "Create User if they don't already exist and return User." [& {:keys [email first last password superuser active] - :or {superuser false - active true}}] + :or {superuser false + active true}}] {:pre [(string? email) (string? first) (string? last) (string? password) (m/boolean? superuser) (m/boolean? active)]} (wait-for-initiailization) (or (User :email email) @@ -87,18 +82,19 @@ :is_active active))) -(s/defn fetch-user :- UserInstance +(defn fetch-user "Fetch the User object associated with USERNAME. Creates user if needed. (fetch-user :rasta) -> {:id 100 :first_name \"Rasta\" ...}" - [username :- TestUserName] + [username] + {:pre [(contains? usernames username)]} (m/mapply fetch-or-create-user! (user->info username))) -(s/defn create-users-if-needed! +(defn create-users-if-needed! "Force creation of the test users if they don't already exist." ([] (apply create-users-if-needed! usernames)) - ([& usernames :- [TestUserName]] + ([& usernames] (doseq [username usernames] ;; fetch-user will force creation of users (fetch-user username)))) @@ -108,15 +104,15 @@ (user->id :rasta) -> 4" (memoize - (s/fn :- s/Int [username :- TestUserName] + (fn [username] {:pre [(contains? usernames username)]} - (u/get-id (fetch-user username))))) + (:id (fetch-user username))))) -(s/defn user->credentials :- {:username (s/pred u/email?), :password s/Str} +(defn user->credentials "Return a map with `:username` and `:password` for User with USERNAME. (user->credentials :rasta) -> {:username \"rasta@metabase.com\", :password \"blueberries\"}" - [username :- TestUserName] + [username] {:pre [(contains? usernames username)]} (let [{:keys [email password]} (user->info username)] {:username email @@ -132,9 +128,9 @@ (defonce ^:private tokens (atom {})) -(s/defn username->token :- u/uuid-regex +(defn username->token "Return cached session token for a test User, logging in first if needed." - [username :- TestUserName] + [username] (or (@tokens username) (u/prog1 (http/authenticate (user->credentials username)) (swap! tokens assoc username <>)) @@ -158,18 +154,18 @@ (clear-cached-session-tokens!) (apply client-fn username args))))) -(s/defn user->client :- (s/pred fn?) +(defn user->client "Returns a `metabase.http-client/client` partially bound with the credentials for User with USERNAME. In addition, it forces lazy creation of the User if needed. ((user->client) :get 200 \"meta/table\")" - [username :- TestUserName] + [username] (create-users-if-needed! username) (partial client-fn username)) -(s/defn do-with-test-user +(defn do-with-test-user "Call `f` with various `metabase.api.common` dynamic vars bound to the test User named by `user-kwd`." - [user-kwd :- TestUserName, f :- (s/pred fn?)] + [user-kwd f] ((mw.session/bind-current-user (fn [_ respond _] (respond (f)))) (let [user-id (user->id user-kwd)] {:metabase-user-id user-id diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index c25c43af3269491db83ad9289a5e72bc07a1d498..c9b7dca0f02a933704ab5d552ae3531aa9a50021 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -3,11 +3,11 @@ (:require [cheshire.core :as json] [clj-time.core :as time] [clojure - [string :as str] + [string :as s] [walk :as walk]] [clojure.tools.logging :as log] [clojurewerkz.quartzite.scheduler :as qs] - [expectations :as expectations :refer [expect]] + [expectations :refer :all] [metabase [driver :as driver] [task :as task] @@ -35,35 +35,11 @@ [metabase.test.data :as data] [metabase.test.data.dataset-definitions :as defs] [metabase.util.date :as du] - [schema.core :as s] [toucan.db :as db] [toucan.util.test :as test]) (:import org.apache.log4j.Logger [org.quartz CronTrigger JobDetail JobKey Scheduler Trigger])) -;; record type for testing that results match a Schema -(defrecord SchemaExpectation [schema] - expectations/CustomPred - (expect-fn [_ actual] - (nil? (s/check schema actual))) - (expected-message [_ _ _ _] - (str "Result did not match schema:\n" - (u/pprint-to-str (s/explain schema)))) - (actual-message [_ actual _ _] - (str "Was:\n" - (u/pprint-to-str actual))) - (message [_ actual _ _] - (u/pprint-to-str (s/check schema actual)))) - -(defmacro expect-schema - "Like `expect`, but checks that results match a schema." - {:style/indent 0} - [expected actual] - `(expect - (SchemaExpectation. ~expected) - ~actual)) - - ;;; ---------------------------------------------------- match-$ ----------------------------------------------------- (defn- $->prop @@ -132,8 +108,8 @@ (every-pred (some-fn keyword? string?) (some-fn #{:id :created_at :updated_at :last_analyzed :created-at :updated-at :field-value-id :field-id :fields_hash :date_joined :date-joined :last_login :dimension-id :human-readable-field-id} - #(str/ends-with? % "_id") - #(str/ends-with? % "_at"))) + #(s/ends-with? % "_id") + #(s/ends-with? % "_at"))) data)) ([pred data] (walk/prewalk (fn [maybe-map] diff --git a/test/metabase/test_setup.clj b/test/metabase/test_setup.clj index ed47f29be45afdbc1bace2c24287cb9bc747fe9e..8d23f9cbad0f968c106e12e004ed2779dcdbe8f3 100644 --- a/test/metabase/test_setup.clj +++ b/test/metabase/test_setup.clj @@ -1,8 +1,12 @@ (ns metabase.test-setup "Functions that run before + after unit tests (setup DB, start web server, load test data)." - (:require [clojure.java.io :as io] - [clojure.string :as str] + (:require [clojure + [data :as data] + [set :as set] + [string :as str]] + [clojure.java.io :as io] [clojure.tools.logging :as log] + [expectations :refer :all] [metabase [db :as mdb] [handler :as handler] @@ -16,6 +20,50 @@ [metabase.test.data.env :as tx.env] [yaml.core :as yaml])) +;;; ---------------------------------------- Expectations Framework Settings ----------------------------------------- + +;; ## EXPECTATIONS FORMATTING OVERRIDES + +;; These overrides the methods Expectations usually uses for printing failed tests. +;; These are basically the same as the original implementations, but they colorize and pretty-print the +;; output, which makes it an order of magnitude easier to read, especially for tests that compare a +;; lot of data, like Query Processor or API tests. +(defn- format-failure [e a str-e str-a] + {:type :fail + :expected-message (when-let [in-e (first (data/diff e a))] + (format "\nin expected, not actual:\n%s" (u/pprint-to-str 'green in-e))) + :actual-message (when-let [in-a (first (data/diff a e))] + (format "\nin actual, not expected:\n%s" (u/pprint-to-str 'red in-a))) + :raw [str-e str-a] + :result ["\nexpected:\n" + (u/pprint-to-str 'green e) + "\nwas:\n" + (u/pprint-to-str 'red a)]}) + +(defmethod compare-expr :expectations/maps [e a str-e str-a] + (let [[in-e in-a] (data/diff e a)] + (if (and (nil? in-e) (nil? in-a)) + {:type :pass} + (format-failure e a str-e str-a)))) + +(defmethod compare-expr :expectations/sets [e a str-e str-a] + (format-failure e a str-e str-a)) + +(defmethod compare-expr :expectations/sequentials [e a str-e str-a] + (let [diff-fn (fn [e a] (seq (set/difference (set e) (set a))))] + (assoc (format-failure e a str-e str-a) + :message (cond + (and (= (set e) (set a)) + (= (count e) (count a)) + (= (count e) (count (set a)))) "lists appear to contain the same items with different ordering" + (and (= (set e) (set a)) + (< (count e) (count a))) "some duplicate items in actual are not expected" + (and (= (set e) (set a)) + (> (count e) (count a))) "some duplicate items in expected are not actual" + (< (count e) (count a)) "actual is larger than expected" + (> (count e) (count a)) "expected is larger than actual")))) + + ;;; ------------------------------- Functions That Get Ran On Test Suite Start / Stop -------------------------------- (defn- driver-plugin-manifest [driver] diff --git a/test/metabase/util_test.clj b/test/metabase/util_test.clj index d84afec808059a58d567c1795fa9d95b65c1bdf2..5a278d0aea3d19e0e3bef6fc28646f23408ab94c 100644 --- a/test/metabase/util_test.clj +++ b/test/metabase/util_test.clj @@ -65,7 +65,7 @@ (expect "cam_s_awesome_toucan_emporium" (slugify "Cam's awesome toucan emporium")) (expect "frequently_used_cards" (slugify "Frequently-Used Cards")) ;; check that diactrics get removed -(expect "cam_saul_s_toucannery" (slugify "Cam Saul's Toucannery")) +(expect "cam_saul_s_toucannery" (slugify "Cam Saül's Toucannery")) (expect "toucans_dislike_pinatas___" (slugify "toucans dislike piñatas :(")) ;; check that non-ASCII characters get URL-encoded (so we can support non-Latin alphabet languages; see #3818) (expect "%E5%8B%87%E5%A3%AB" (slugify "勇士")) ; go dubs