From efd28c7e29aad373942ed79b745959aa6d173412 Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Fri, 2 Dec 2022 13:26:59 -0800 Subject: [PATCH] config-from-file should check that we have a premium token with `:advanced-config` (#26463) * Move the config-from-file code into the advanced-config directory since that's the feature we want to flag on * Always apply the :settings section first in the config file * config-from-file should check that we have a premium token with the :advanced-config feature * Sort namespace * Settings does not have to come first anymore * Remove empty `binding` forms * Test fix? :wrench: * Test fix? :wrench: * Fix failing tests, for real this time * Revert "Fix failing tests, for real this time" This reverts commit 0a57055b1be249d9ba5b6b6ebe4f163e1f21e2b2. * Don't make the tests parallel since that breaks other stuff Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com> --- .../advanced_config/file.clj | 10 +- .../advanced_config/file/databases_test.clj | 50 +++--- .../advanced_config/file/settings_test.clj | 55 ++++--- .../advanced_config/file/users_test.clj | 146 +++++++++--------- .../advanced_config/file_test.clj | 29 ++-- .../public_settings/premium_features_test.clj | 33 ++-- 6 files changed, 174 insertions(+), 149 deletions(-) diff --git a/enterprise/backend/src/metabase_enterprise/advanced_config/file.clj b/enterprise/backend/src/metabase_enterprise/advanced_config/file.clj index b34a94e32ef..b54972239ad 100644 --- a/enterprise/backend/src/metabase_enterprise/advanced_config/file.clj +++ b/enterprise/backend/src/metabase_enterprise/advanced_config/file.clj @@ -104,9 +104,10 @@ [metabase-enterprise.advanced-config.file.users] [metabase.driver.common.parameters] [metabase.driver.common.parameters.parse :as params.parse] + [metabase.public-settings.premium-features :as premium-features] [metabase.util :as u] [metabase.util.files :as u.files] - [metabase.util.i18n :refer [trs]] + [metabase.util.i18n :refer [trs tru]] [yaml.core :as yaml])) (comment @@ -252,6 +253,13 @@ ;; enabling that check tho) (when-let [m (config)] (doseq [[section-name section-config] (sort-by-initialization-order (:config m))] + ;; you can only use the config-from-file stuff with an EE/Pro token with the `:advanced-config` feature. Since you + ;; might have to use the `:settings` section to set the token, skip the check for Settings. But check it for the + ;; other sections. + (when-not (= section-name :settings) + (when-not (premium-features/has-feature? :advanced-config) + (throw (ex-info (tru "Metabase config files require a Premium token with the :advanced-config feature.") + {})))) (log/info (u/colorize :magenta (trs "Initializing {0} from config file..." section-name)) (u/emoji "🗄ï¸")) (advanced-config.file.i/initialize-section! section-name section-config)) (log/info (u/colorize :magenta (trs "Done initializing from file.")) (u/emoji "🗄ï¸"))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/databases_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/databases_test.clj index d00488f6ce5..0aad0264c2a 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/databases_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/databases_test.clj @@ -1,14 +1,19 @@ (ns metabase-enterprise.advanced-config.file.databases-test (:require [clojure.test :refer :all] - [flatland.ordered.map :as ordered-map] [metabase-enterprise.advanced-config.file :as advanced-config.file] [metabase.db.connection :as mdb.connection] [metabase.models :refer [Database Table]] + [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.test :as mt] [metabase.util :as u] [toucan.db :as db])) +(use-fixtures :each (fn [thunk] + (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] + (premium-features-test/with-premium-features #{:advanced-config} + (thunk))))) + (def ^:private test-db-name (u/qualified-name ::test-db)) (deftest init-from-config-file-test @@ -16,11 +21,10 @@ (let [db-type (mdb.connection/db-type) original-db (mt/with-driver db-type (mt/db))] (try - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1} - advanced-config.file/*config* {:version 1 - :config {:databases [{:name test-db-name - :engine (name db-type) - :details (:details original-db)}]}}] + (binding [advanced-config.file/*config* {:version 1 + :config {:databases [{:name test-db-name + :engine (name db-type) + :details (:details original-db)}]}}] (testing "Create a Database if it does not already exist" (is (= :ok (advanced-config.file/initialize!))) @@ -42,13 +46,12 @@ (finally (db/delete! Database :name test-db-name)))))) -(deftest ^:parallel init-from-config-file-connection-validation-test +(deftest init-from-config-file-connection-validation-test (testing "Validate connection details when creating a Database from a config file, and error if they are invalid" - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1} - advanced-config.file/*config* {:version 1 - :config {:databases [{:name (str test-db-name "-in-memory") - :engine "h2" - :details {:db "mem:some-in-memory-db"}}]}}] + (binding [advanced-config.file/*config* {:version 1 + :config {:databases [{:name (str test-db-name "-in-memory") + :engine "h2" + :details {:db "mem:some-in-memory-db"}}]}}] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Database cannot be found\." @@ -59,24 +62,11 @@ ;; make sure we're actually testing something if it was already set to false locally. (mt/with-temporary-setting-values [config-from-file-sync-databases true] (try - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1} - advanced-config.file/*config* {:version 1 - :config - ;; `settings:` HAS to come before `databases:`, otherwise the - ;; flag won't be set when database sync stuff happens. - ;; - ;; Using a [[flatland.ordered.map]] here really isn't necessary - ;; since this map only has two keys and will be created as an - ;; `ArrayMap`, preserving the originally specified order... but - ;; using [[ordered-map]] explicitly here makes this constraint - ;; clearer I think. Also the YAML library reads stuff in as an - ;; ordered map so this more closely matches the behavior when - ;; using a file - (ordered-map/ordered-map - :settings {:config-from-file-sync-databases false} - :databases [{:name test-db-name - :engine "h2" - :details (:details (mt/db))}])}] + (binding [advanced-config.file/*config* {:version 1 + :config {:settings {:config-from-file-sync-databases false} + :databases [{:name test-db-name + :engine "h2" + :details (:details (mt/db))}]}}] (testing "Create a Database since it does not already exist" (is (= :ok (advanced-config.file/initialize!))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj index 2cb17135ad6..89fe95c139f 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj @@ -2,7 +2,13 @@ (:require [clojure.test :refer :all] [metabase-enterprise.advanced-config.file :as advanced-config.file] - [metabase.models.setting :refer [defsetting]])) + [metabase.models.setting :refer [defsetting]] + [metabase.public-settings.premium-features-test :as premium-features-test])) + +(use-fixtures :each (fn [thunk] + (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] + (premium-features-test/with-premium-features #{:advanced-config} + (thunk))))) (defsetting config-from-file-settings-test-setting "Internal test setting." @@ -11,29 +17,28 @@ (deftest settings-test (testing "Should be able to set settings with config-from-file" (config-from-file-settings-test-setting! nil) - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] - (testing "happy path" - (binding [advanced-config.file/*config* {:version 1 - :config {:settings {:config-from-file-settings-test-setting "wow"}}}] - (advanced-config.file/initialize!) - (is (= "wow" - (config-from-file-settings-test-setting))))) - (testing "Wrong value type should throw an error." - (binding [advanced-config.file/*config* {:version 1 - :config {:settings {:config-from-file-settings-test-setting 1000}}}] + (testing "happy path" + (binding [advanced-config.file/*config* {:version 1 + :config {:settings {:config-from-file-settings-test-setting "wow"}}}] + (advanced-config.file/initialize!) + (is (= "wow" + (config-from-file-settings-test-setting))))) + (testing "Wrong value type should throw an error." + (binding [advanced-config.file/*config* {:version 1 + :config {:settings {:config-from-file-settings-test-setting 1000}}}] - (is (thrown-with-msg? - clojure.lang.ExceptionInfo - #"Input .* does not match schema" - (advanced-config.file/initialize!))) - (testing "value should not have been updated" - (is (= "wow" - (config-from-file-settings-test-setting)))))) - (testing "Invalid Setting (does not exist)" - (binding [advanced-config.file/*config* {:version 1 - :config {:settings {:config-from-file-settings-test-setting-FAKE 1000}}}] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Input .* does not match schema" + (advanced-config.file/initialize!))) + (testing "value should not have been updated" + (is (= "wow" + (config-from-file-settings-test-setting)))))) + (testing "Invalid Setting (does not exist)" + (binding [advanced-config.file/*config* {:version 1 + :config {:settings {:config-from-file-settings-test-setting-FAKE 1000}}}] - (is (thrown-with-msg? - clojure.lang.ExceptionInfo - #"Unknown setting: :config-from-file-settings-test-setting-FAKE" - (advanced-config.file/initialize!)))))))) + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Unknown setting: :config-from-file-settings-test-setting-FAKE" + (advanced-config.file/initialize!))))))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj index 92176f8a30b..24f6e1902a2 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj @@ -4,17 +4,22 @@ [metabase-enterprise.advanced-config.file :as advanced-config.file] [metabase-enterprise.advanced-config.file.users :as advanced-config.file.users] [metabase.models :refer [User]] + [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.util.password :as u.password] [toucan.db :as db])) +(use-fixtures :each (fn [thunk] + (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] + (premium-features-test/with-premium-features #{:advanced-config} + (thunk))))) + (deftest init-from-config-file-test (try - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1} - advanced-config.file/*config* {:version 1 - :config {:users [{:first_name "Cam" - :last_name "Era" - :email "cam+config-file-test@metabase.com" - :password "2cans"}]}}] + (binding [advanced-config.file/*config* {:version 1 + :config {:users [{:first_name "Cam" + :last_name "Era" + :email "cam+config-file-test@metabase.com" + :password "2cans"}]}}] (testing "Create a User if it does not already exist" (is (= :ok (advanced-config.file/initialize!))) @@ -56,58 +61,56 @@ (deftest init-from-config-file-force-admin-for-first-user-test (testing "If this is the first user being created, always make the user a superuser regardless of what is specified" (try - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] - (testing "Create the first User" - (binding [advanced-config.file/*config* {:version 1 - :config {:users [{:first_name "Cam" - :last_name "Era" - :email "cam+config-file-admin-test@metabase.com" - :password "2cans" - :is_superuser false}]}}] - (with-redefs [advanced-config.file.users/init-from-config-file-is-first-user? (constantly true)] - (is (= :ok - (advanced-config.file/initialize!))) - (is (partial= {:first_name "Cam" - :last_name "Era" - :email "cam+config-file-admin-test@metabase.com" - :is_superuser true} - (db/select-one User :email "cam+config-file-admin-test@metabase.com"))) - (is (= 1 - (db/count User :email "cam+config-file-admin-test@metabase.com")))))) - (testing "Create the another User, DO NOT force them to be an admin" - (binding [advanced-config.file/*config* {:version 1 - :config {:users [{:first_name "Cam" - :last_name "Saul" - :email "cam+config-file-admin-test-2@metabase.com" - :password "2cans" - :is_superuser false}]}}] + (testing "Create the first User" + (binding [advanced-config.file/*config* {:version 1 + :config {:users [{:first_name "Cam" + :last_name "Era" + :email "cam+config-file-admin-test@metabase.com" + :password "2cans" + :is_superuser false}]}}] + (with-redefs [advanced-config.file.users/init-from-config-file-is-first-user? (constantly true)] (is (= :ok (advanced-config.file/initialize!))) (is (partial= {:first_name "Cam" - :last_name "Saul" - :email "cam+config-file-admin-test-2@metabase.com" - :is_superuser false} - (db/select-one User :email "cam+config-file-admin-test-2@metabase.com"))) + :last_name "Era" + :email "cam+config-file-admin-test@metabase.com" + :is_superuser true} + (db/select-one User :email "cam+config-file-admin-test@metabase.com"))) (is (= 1 - (db/count User :email "cam+config-file-admin-test-2@metabase.com")))))) + (db/count User :email "cam+config-file-admin-test@metabase.com")))))) + (testing "Create the another User, DO NOT force them to be an admin" + (binding [advanced-config.file/*config* {:version 1 + :config {:users [{:first_name "Cam" + :last_name "Saul" + :email "cam+config-file-admin-test-2@metabase.com" + :password "2cans" + :is_superuser false}]}}] + (is (= :ok + (advanced-config.file/initialize!))) + (is (partial= {:first_name "Cam" + :last_name "Saul" + :email "cam+config-file-admin-test-2@metabase.com" + :is_superuser false} + (db/select-one User :email "cam+config-file-admin-test-2@metabase.com"))) + (is (= 1 + (db/count User :email "cam+config-file-admin-test-2@metabase.com"))))) (finally (db/delete! User :email [:in #{"cam+config-file-admin-test@metabase.com" "cam+config-file-admin-test-2@metabase.com"}]))))) (deftest init-from-config-file-env-var-for-password-test (testing "Ensure that we can set User password using {{env ...}} templates" (try - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1} - advanced-config.file/*config* {:version 1 - :config {:users [{:first_name "Cam" - :last_name "Era" - :email "cam+config-file-password-test@metabase.com" - :password "{{env USER_PASSWORD}}"}]}} - advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :user-password "1234cans")] + (binding [advanced-config.file/*config* {:version 1 + :config {:users [{:first_name "Cam" + :last_name "Era" + :email "cam+config-file-password-test@metabase.com" + :password "{{env USER_PASSWORD}}"}]}} + advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :user-password "1234cans")] (testing "Create a User if it does not already exist" (is (= :ok (advanced-config.file/initialize!))) (let [user (db/select-one [User :first_name :last_name :email :password_salt :password] - :email "cam+config-file-password-test@metabase.com")] + :email "cam+config-file-password-test@metabase.com")] (is (partial= {:first_name "Cam" :last_name "Era" :email "cam+config-file-password-test@metabase.com"} @@ -116,34 +119,33 @@ (finally (db/delete! User :email "cam+config-file-password-test@metabase.com"))))) -(deftest ^:parallel init-from-config-file-validation-test - (binding [advanced-config.file/*supported-versions* {:min 1, :max 1}] - (are [user error-pattern] (thrown-with-msg? - clojure.lang.ExceptionInfo - error-pattern - (binding [advanced-config.file/*config* {:version 1 - :config {:users [user]}}] - (#'advanced-config.file/config))) - ;; missing email - {:first_name "Cam" - :last_name "Era" - :password "2cans"} - (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :email)")) +(deftest init-from-config-file-validation-test + (are [user error-pattern] (thrown-with-msg? + clojure.lang.ExceptionInfo + error-pattern + (binding [advanced-config.file/*config* {:version 1 + :config {:users [user]}}] + (#'advanced-config.file/config))) + ;; missing email + {:first_name "Cam" + :last_name "Era" + :password "2cans"} + (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :email)")) - ;; missing first name - {:last_name "Era" - :email "cam+config-file-admin-test@metabase.com" - :password "2cans"} - (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :first_name)")) + ;; missing first name + {:last_name "Era" + :email "cam+config-file-admin-test@metabase.com" + :password "2cans"} + (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :first_name)")) - ;; missing last name - {:first_name "Cam" - :email "cam+config-file-admin-test@metabase.com" - :password "2cans"} - (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :last_name)")) + ;; missing last name + {:first_name "Cam" + :email "cam+config-file-admin-test@metabase.com" + :password "2cans"} + (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :last_name)")) - ;; missing password - {:first_name "Cam" - :last_name "Era" - :email "cam+config-file-test@metabase.com"} - (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :password)"))))) + ;; missing password + {:first_name "Cam" + :last_name "Era" + :email "cam+config-file-test@metabase.com"} + (re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :password)")))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file_test.clj index 273b31b684b..201aa13cf55 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file_test.clj @@ -5,13 +5,15 @@ [clojure.walk :as walk] [metabase-enterprise.advanced-config.file :as advanced-config.file] [metabase-enterprise.advanced-config.file.interface :as advanced-config.file.i] + [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.test :as mt] [metabase.util :as u] [yaml.core :as yaml])) (use-fixtures :each (fn [thunk] (binding [advanced-config.file/*supported-versions* {:min 1.0, :max 1.999}] - (thunk)))) + (premium-features-test/with-premium-features #{:advanced-config} + (thunk))))) (defn- re-quote [^String s] (re-pattern (java.util.regex.Pattern/quote s))) @@ -20,7 +22,7 @@ {:version 1 :config {:settings {:my-setting "abc123"}}}) -(deftest ^:parallel config-test +(deftest config-test (testing "Specify a custom path and read from YAML" (mt/with-temp-file [filename "temp-config-file.yml"] (spit filename (yaml/generate-string mock-yaml)) @@ -34,7 +36,7 @@ :config {:settings {:my-setting "abc123"}}} (#'advanced-config.file/config)))))) -(deftest ^:parallel validate-config-test +(deftest validate-config-test (testing "Config should throw an error" (testing "if it is not a map" (binding [advanced-config.file/*config* [1 2 3 4]] @@ -68,7 +70,7 @@ (defn- mock-config-with-setting [s] {:version 1.0, :config {:settings {:my-setting s}}}) -(deftest ^:parallel expand-template-forms-test +(deftest expand-template-forms-test (testing "Ignore single curly brackets, or brackets with spaces between them" (are [s] (= (mock-config-with-setting s) (binding [advanced-config.file/*config* (mock-config-with-setting s)] @@ -92,14 +94,14 @@ ;; unknown template type "{{bird \"Parrot Hilton\"}}" (re-quote "bird - failed: valid-template-type?")))) -(deftest ^:parallel recursive-template-form-expansion-test +(deftest recursive-template-form-expansion-test (testing "Recursive expansion is unsupported, for now." (binding [advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :x "{{env Y}}", :y "Y") advanced-config.file/*config* (mock-config-with-setting "{{env X}}")] (is (= (mock-config-with-setting "{{env Y}}") (#'advanced-config.file/config)))))) -(deftest ^:parallel expand-template-env-var-values-test +(deftest expand-template-env-var-values-test (testing "env var values" (binding [advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :config-file-bird-name "Parrot Hilton")] (testing "Nothing weird" @@ -125,7 +127,7 @@ (is (= (mock-config-with-setting "Parrot Hilton") (#'advanced-config.file/config)))))))) -(deftest ^:parallel expand-template-env-var-values-validation-test +(deftest expand-template-env-var-values-validation-test (testing "(config) should walk the config map and expand {{template}} forms" (testing "env var values" (testing "validation" @@ -141,7 +143,7 @@ ;; wrong env var type "{{env :SOME_ENV_VAR}}" (re-quote "SOME_ENV_VAR - failed: symbol?")))))) -(deftest ^:parallel optional-template-test +(deftest optional-template-test (testing "[[optional {{template}}]] values" (binding [advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :my-sensitive-password "~~~SeCrEt123~~~")] (testing "env var exists" @@ -176,7 +178,16 @@ (is (= [[:warn nil (u/colorize :yellow "Ignoring unknown config section :unknown-section.")]] log-messages)))))) -(deftest ^:parallel error-validation-do-not-leak-env-vars-test +(deftest require-advanced-config-test + (testing "Config files should require the `:advanced-config` token feature" + (premium-features-test/with-premium-features #{} + (binding [advanced-config.file/*config* {:version 1.0, :config {:unknown-section {}}}] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Metabase config files require a Premium token with the :advanced-config feature" + (advanced-config.file/initialize!))))))) + +(deftest error-validation-do-not-leak-env-vars-test (testing "spec errors should not include contents of env vars -- expand templates after spec validation." (binding [advanced-config.file/*env* (assoc @#'advanced-config.file/*env* :my-sensitive-password "~~~SeCrEt123~~~") advanced-config.file/*config* {:version 1 diff --git a/test/metabase/public_settings/premium_features_test.clj b/test/metabase/public_settings/premium_features_test.clj index 6bb1a28178c..186f871da99 100644 --- a/test/metabase/public_settings/premium_features_test.clj +++ b/test/metabase/public_settings/premium_features_test.clj @@ -78,21 +78,30 @@ :error-details "network issues"} (premium-features/fetch-token-status (apply str (repeat 64 "b"))))))) (testing "Only attempt the token once" - (let [call-count (atom 0)] + (let [call-count (atom 0) + token (random-token)] (binding [clj-http.client/request (fn [& _] (swap! call-count inc) (throw (Exception. "no internet")))] - (mt/with-temporary-raw-setting-values [:premium-embedding-token (random-token)] - (doseq [premium-setting [premium-features/hide-embed-branding? - premium-features/enable-whitelabeling? - premium-features/enable-audit-app? - premium-features/enable-sandboxes? - premium-features/enable-sso? - premium-features/enable-advanced-config? - premium-features/enable-content-management?]] - (is (false? (premium-setting)) - (str (:name (meta premium-setting)) "is not false"))) - (is (= @call-count 1)))))) + + (mt/with-temporary-raw-setting-values [:premium-embedding-token token] + (testing "Sanity check" + (is (= token + (premium-features/premium-embedding-token))) + (is (= #{} + (#'premium-features/token-features)))) + (doseq [has-feature? [#'premium-features/hide-embed-branding? + #'premium-features/enable-whitelabeling? + #'premium-features/enable-audit-app? + #'premium-features/enable-sandboxes? + #'premium-features/enable-sso? + #'premium-features/enable-advanced-config? + #'premium-features/enable-content-management? + #'premium-features/enable-serialization?]] + (testing (format "\n%s is false" (:name (meta has-feature?))) + (is (not (has-feature?))))) + (is (= 1 + @call-count)))))) (testing "With a valid token" (let [result (token-status-response random-fake-token {:status 200 -- GitLab