* add saved question extraction prototype * fix additional failure cases * refactor: reconciler * revert me: manual skill file edit for source swapping * set data permissions to view for databases referenced in the query * rename data app collection and group * prevent agents from removing savedQuestionSourceId * docs: clarify data app query location * chore: cleanup query-sync canonical module * refactor: split query tests into different files * Swap query source for data app query hooks and apply dynamic filters on top of it (#79584) * code improvements and bug-fixes for data apps permissions * Add data apps model imports * address reviews on data apps permissions * run sync queries before vite build * Add data app resource link (#79878) add button to view data app resources * Show permission error when users cannot access a data app (#79874) show permission error page when user do not have permission to view data apps * Add data app group link in overflow menu (#79873) Add data app group link * Store and reconcile data app entity ids in data_app.yaml (#79833) * Store data app resource entity IDs * make entity ids in data_app.yaml authoritative * add top-level value after path key * simplify data_app.yaml manifest parser * only allow changing data app collection if empty * consolidate backend test helpers and remove redundant tests * chore: restore README changes * ci: fix failing tests * Add support of actions to Data App reconciler (#79842) Add support of actions to Data App reconciler * Fix bugs/inconsistency and add more e2e for data apps reconciler/syncing (#80002) * Update semantic schema skill about preventing segments and measures in dynamic parts of a query in Data Apps (#80288) * Grant view data access on a table-by-table basis in data app groups (#80189) * grant view data on table instead of database level * refactor table scan in reconciler * update e2e tests for permission grants * Grant data app view permissions per table * Skip unchanged data app permissions * add e2e data app test for sandboxing * reset host app sources after runs * Preserve sandboxing for data app users (#80406) * add e2e data app test for sandboxing * improve e2e tests for data apps * Remove sandbox E2E from table permissions change * preserve existing sandboxing from sandboxed groups in data apps * assert that we grant non-sandboxed tables * Explain data app sandbox exclusion * Add data app sandbox E2E coverage * Exercise sandbox preservation in data app E2E * Disable saved question sources in data apps (#80139) * Disable saved question sources in data apps * Preserve question-only schema scope * Remove the saved question schema parameter * Copy table-sourced metrics into data app collection and do not codegen card-sourced metrics (#80086) * copy table-sourced metrics into the data app collections * copy table-sourced metrics into the data app collections * Address metric sync review feedback * Accept keyword metric display in query response * Fix data app metric reconciliation ordering * Remove old lockfile format * exclude card-sourced metrics from schema generator * do not use card terms in metrics to avoid ambiguity with card-sourced metrics * fix model imports * Fix data app metric reconciliation * refactor: cleanup reconciler unit tests * Fix duplicated metric client helper * add safe metrics path * Handle unsupported metric dependencies * Fix data app CI test regressions * Fix "Re-adding removed query fails to adopt deleted card" (#80341) * Clean-up data apps permissions endpoints (#80825) * Remove entity ids from data_app.yaml (#81317) Remove data app entity ids from data_app.yaml * Adjust data apps permissions code (#81093) * Remove the data app table permission grants (#81584) * Add permissions_group.is_data_app_group and hide data app permission groups from UI (#81705) * Add data app user management and access warnings (#81839) * Address data app user access review feedback * additional e2e test cases for bugs in manage users page * fix failing frontend type check on unit test * fix migration id collision for data apps * combine migration changes into one file * fix data app metric sync after trashing copied metrics * fix cli runtime dependency packaging for embedding sdk * avoid loading data app bundle during resource setup * prevent redundant data app collection permission updates * allow admins to remove data app members after token expires * update docs and add e2e test for missing resource collection case * isolate data app sync using transactions * do not leave user on an empty page when removing the last member on the page * reserve sandbox-host slug in data app config * ignore generated data app action fixtures * move queries into db.clj * preserve legacy permissions when adding data app members * regenerate the kondo modules config The data-apps module reaches for :model/Permissions and :model/DataPermissions, neither of which was recorded in :model-imports, so metabase.core.modules-test fails on the current tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP * reject data app queries whose metrics read another card Sync copies a referenced metric but rewrites nothing inside the copy, and copies nothing that copy in turn reads, so those references still point at the originals. The app publishes successfully and then fails for every viewer without access to the originals' collections. Checking while resolving the query, rather than at codegen, also catches a metric edited into that shape after its schema was generated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP * exclude metrics that read a saved card anywhere from generated schemas source-card-id only reads stage 0, so a table-sourced metric that joins a saved question passed the codegen filter. The CLI checks the whole query and rejects such a metric, aborting sync-resources for any app that uses it. Checking the whole query here keeps codegen consistent with what sync supports, and removes the database round trip that asked which of the referenced cards were metrics -- the step that let a saved-question join through in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP * resolve copied resources' table dependencies through the server Numeric source-table entries miss implicit joins: a copied model querying Orders and reading Products.Category through a foreign key names Products nowhere, so it was left out of the app's permission warnings. Run the same metadata-based lookup the query endpoint uses, through a new endpoint the CLI calls for copied models, actions and metrics, and drop the JSON walk it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP * cover the create-queries grant the database defaults leave on app groups An app group that predates the first non-audit database is given create-queries :query-builder-and-native by set-new-database-permissions!, alongside the :blocked view-data it special-cases for app groups. A user with data access through another group would gain native query authoring just by joining the app. The reconciler already resets it, but nothing failed when :perms/create-queries was dropped from its list. The pairing cannot be produced through set-database-permission!, which cascades view-data to :unrestricted for any create-queries value other than :no, so the test writes the row the way the defaults insert it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP * prevent models based on saved questions from being copied * return to a valid member page after overlapping removals * do not resolve query objects when either enabled flag is false * prevent shorthand properties and spreads from overriding generated ids * prevent adding duplicate members and retry only members that are failed to add * define schema for data app query endpoint * reconcile only the current data app group during resource setup * expand the schema fingerprint * add data app group field in permission group schemas * prevent new databases from granting query creation to data app groups * throw validation errors when invalid query spec fields are passed * do not fail validation on columns metadata * add enabled metadata to query definition schema * accept more data app specific query metadata and binning options --------- Co-authored-by: Timofey Kachalov <timofei@metabase.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
365 lines
22 KiB
Clojure
365 lines
22 KiB
Clojure
(ns metabase-enterprise.data-apps.sync-test
|
|
"Unit coverage for the materialization edge cases not exercised through the API
|
|
suite: the `:changed` accounting (content vs. sha/timestamp bumps) and the
|
|
oversized-bundle guard (rejected with a sync_error, previously cached bundle
|
|
retained)."
|
|
(:require
|
|
[clojure.string :as str]
|
|
[clojure.test :refer :all]
|
|
[metabase-enterprise.data-apps.db :as data-apps.db]
|
|
[metabase-enterprise.data-apps.resources :as data-app.resources]
|
|
[metabase-enterprise.data-apps.sync :as data-app.sync]
|
|
[metabase-enterprise.remote-sync.source :as source]
|
|
[metabase.permissions.core :as perms]
|
|
[metabase.test :as mt]
|
|
[toucan2.core :as t2]))
|
|
|
|
(set! *warn-on-reflection* true)
|
|
|
|
(def ^:private fake-sha "0123456789abcdef0123456789abcdef01234567")
|
|
|
|
(defn- snapshot
|
|
[path->content & {:keys [sha] :or {sha fake-sha}}]
|
|
{:sha sha
|
|
:list-dir (fn [dir] (source/paths->children (keys path->content) dir))
|
|
:read-file (fn [p] (get path->content p))})
|
|
|
|
(defn- app-files
|
|
"The repo files for one app in `data_apps/<dir>`. `dir` is the app's slug — the
|
|
config declares no slug, it is the directory's name."
|
|
[dir {:keys [name path bundle description]}]
|
|
{(format "data_apps/%s/data_app.yaml" dir)
|
|
(str (format "name: %s\npath: %s\n" name path)
|
|
(when description (format "description: %s\n" description)))
|
|
(format "data_apps/%s/%s" dir path) bundle})
|
|
|
|
(deftest sync-from-snapshot-is-gated-by-the-data-apps-feature-test
|
|
(testing "the remote-sync entry point does nothing without the :data-apps-preview feature —
|
|
an instance then behaves exactly as if data apps did not exist"
|
|
(let [files (app-files "a" {:name "A" :path "index.js" :bundle "V1"})]
|
|
(mt/with-premium-features #{}
|
|
(mt/with-model-cleanup [:model/DataApp]
|
|
(is (nil? (data-app.sync/sync-from-snapshot! (snapshot files)))
|
|
"returns nil rather than a sync result")
|
|
(is (not (t2/exists? :model/DataApp :name "a"))
|
|
"materializes no data app")))
|
|
(mt/with-premium-features #{:data-apps-preview}
|
|
(mt/with-model-cleanup [:model/DataApp]
|
|
(is (=? {:synced 1 :changed 1}
|
|
(data-app.sync/sync-from-snapshot! (snapshot files)))
|
|
"with the feature it materializes the app as usual")
|
|
(is (t2/exists? :model/DataApp :name "a")))))))
|
|
|
|
(deftest resource-provisioning-failures-are-isolated-test
|
|
(mt/with-model-cleanup [:model/DataApp]
|
|
(let [files (merge (app-files "broken" {:name "Broken" :path "index.js" :bundle "BROKEN"})
|
|
(app-files "working" {:name "Working" :path "index.js" :bundle "WORKING"}))]
|
|
(with-redefs [data-app.resources/ensure-resources!
|
|
(fn [app]
|
|
(when (= "broken" (:name app))
|
|
(throw (ex-info "Resource provisioning failed." {}))))]
|
|
(is (=? {:synced 2 :changed 2}
|
|
(data-app.sync/import-from-snapshot! (snapshot files))))
|
|
(is (= "Resource provisioning failed."
|
|
(t2/select-one-fn :sync_error :model/DataApp :name "broken")))
|
|
(is (nil? (t2/select-one-fn :sync_error :model/DataApp :name "working")))))))
|
|
|
|
(deftest sync-creates-stable-permission-resources-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [database-id (mt/id)
|
|
files (app-files "sales" {:name "Sales" :path "index.js" :bundle "V1"})]
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
(let [{:keys [resource_collection_id permission_group_id]}
|
|
(t2/select-one :model/DataApp :name "sales")]
|
|
(testing "the first sync creates a dedicated group and collection"
|
|
(is (pos-int? resource_collection_id))
|
|
(is (pos-int? permission_group_id))
|
|
(is (= "Data App: sales"
|
|
(t2/select-one-fn :name :model/Collection :id resource_collection_id)))
|
|
(is (= "Data App: sales"
|
|
(t2/select-one-fn :name :model/PermissionsGroup :id permission_group_id)))
|
|
(is (t2/exists? :model/Permissions
|
|
:group_id permission_group_id
|
|
:object (perms/collection-read-path resource_collection_id)))
|
|
(is (not (t2/exists? :model/Permissions
|
|
:group_id permission_group_id
|
|
:object (perms/collection-readwrite-path resource_collection_id))))
|
|
(let [permissions (t2/select-fn-vec :perm_value :model/DataPermissions
|
|
:group_id permission_group_id
|
|
:db_id database-id
|
|
:perm_type :perms/create-queries)]
|
|
(is (seq permissions))
|
|
(is (every? #(= :no %) permissions))))
|
|
(testing "later syncs reuse the same resources"
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
(is (=? {:resource_collection_id resource_collection_id
|
|
:permission_group_id permission_group_id}
|
|
(t2/select-one :model/DataApp :name "sales"))))
|
|
(testing "removing the app deletes its resources"
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))
|
|
(is (not (t2/exists? :model/Collection :id resource_collection_id)))
|
|
(is (not (t2/exists? :model/PermissionsGroup :id permission_group_id))))))))
|
|
|
|
(deftest sync-restores-deleted-collection-and-permission-group-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [files (app-files "sales" {:name "Sales" :path "index.js" :bundle "V1"})]
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
(let [before (select-keys (t2/select-one :model/DataApp :name "sales")
|
|
[:resource_collection_id :permission_group_id])]
|
|
(t2/delete! :model/Collection :id (:resource_collection_id before))
|
|
(t2/delete! :model/PermissionsGroup :id (:permission_group_id before))
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
; after syncing the app again, the data app collection and
|
|
; permission group should be re-created
|
|
(let [after (select-keys (t2/select-one :model/DataApp :name "sales") (keys before))]
|
|
(is (every? pos-int? (vals after)))
|
|
(is (t2/exists? :model/Collection :id (:resource_collection_id after)))
|
|
(is (t2/exists? :model/PermissionsGroup :id (:permission_group_id after)))
|
|
(is (not= before after)))))))
|
|
|
|
(deftest changed-count-tracks-content-not-sha-bumps-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [files (app-files "a" {:name "A" :path "index.js" :bundle "V1"})]
|
|
(testing "the first sync counts the new app"
|
|
(is (=? {:synced 1 :changed 1}
|
|
(data-app.sync/import-from-snapshot! (snapshot files)))))
|
|
(testing "re-syncing identical content at a new sha is not a change"
|
|
(is (=? {:synced 1 :changed 0}
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot files :sha "ffffffffffffffffffffffffffffffffffffffff")))))
|
|
(testing "a metadata-only change counts"
|
|
(is (=? {:changed 1}
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "a" {:name "A renamed" :path "index.js" :bundle "V1"}))))))
|
|
(testing "a bundle content change counts"
|
|
(is (=? {:changed 1}
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "a" {:name "A renamed" :path "index.js" :bundle "V2"})))))))))
|
|
|
|
(deftest description-is-optional-and-tracked-like-other-metadata-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [sync-app (fn [& {:as app}]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "a" (merge {:name "A" :path "index.js" :bundle "V1"} app)))))]
|
|
(testing "an app that declares no description syncs with a nil one"
|
|
(sync-app)
|
|
(is (nil? (t2/select-one-fn :description :model/DataApp :name "a"))))
|
|
(testing "adding one counts as a change and is materialized"
|
|
(is (=? {:changed 1} (sync-app :description "What this app does")))
|
|
(is (= "What this app does" (t2/select-one-fn :description :model/DataApp :name "a"))))
|
|
(testing "re-syncing the same description is not a change"
|
|
(is (=? {:changed 0} (sync-app :description "What this app does"))))
|
|
(testing "dropping it from the config clears the column"
|
|
(is (=? {:changed 1} (sync-app)))
|
|
(is (nil? (t2/select-one-fn :description :model/DataApp :name "a")))))))
|
|
|
|
(deftest metadata-edits-count-while-an-app-keeps-failing-test
|
|
(testing "an app whose bundle is missing still stores metadata edits, so they count as changes"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [sync-app (fn [& {:as app}]
|
|
(let [files (app-files "a" (merge {:name "A" :path "index.js" :bundle "unused"} app))]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (dissoc files "data_apps/a/index.js")))))]
|
|
(is (=? {:changed 1} (sync-app :description "First")) "the first failure is a change")
|
|
(is (some? (t2/select-one-fn :sync_error :model/DataApp :name "a")))
|
|
(testing "re-syncing the same failing app unchanged is not a change"
|
|
(is (=? {:changed 0} (sync-app :description "First"))))
|
|
(testing "editing the description is a change even though the bundle still fails"
|
|
(is (=? {:changed 1} (sync-app :description "Second")))
|
|
(is (= "Second" (t2/select-one-fn :description :model/DataApp :name "a"))
|
|
"the edit is stored, which is why the pull cannot report it as a no-op"))))))
|
|
|
|
(deftest switching-repos-prunes-old-apps-overrides-shared-adds-new-test
|
|
(testing "syncing a different repo: drop apps only the old repo had, override shared slugs, add new ones"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
;; Repo A: Foo + Bar
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (merge (app-files "foo" {:name "Foo" :path "index.js" :bundle "FOO"})
|
|
(app-files "bar" {:name "Bar A" :path "index.js" :bundle "BAR-A"}))))
|
|
(is (= #{"foo" "bar"} (t2/select-fn-set :name :model/DataApp)))
|
|
;; Repo B (Bar + Baz): the repo is the source of truth, so Foo (absent from B)
|
|
;; is pruned, Bar is overridden by slug, and Baz is added.
|
|
(is (=? {:synced 2 :removed 1}
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (merge (app-files "bar" {:name "Bar B" :path "index.js" :bundle "BAR-B"})
|
|
(app-files "baz" {:name "Baz" :path "index.js" :bundle "BAZ"}))))))
|
|
(is (= #{"bar" "baz"} (t2/select-fn-set :name :model/DataApp))
|
|
"Foo (only in repo A) is dropped; Baz (from repo B) is added")
|
|
(let [bar (t2/select-one :model/DataApp :name "bar")]
|
|
(is (= "Bar B" (:display_name bar))
|
|
"Bar is overridden in place by repo B (shared slug)")
|
|
(is (= "BAR-B" (String. ^bytes (:bundle bar) "UTF-8"))
|
|
"Bar's cached bundle is repo B's")))))
|
|
|
|
(deftest an-empty-repo-prunes-all-apps-test
|
|
(testing "syncing a repo with no data_apps/ removes every app (the repo has none)"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "solo" {:name "Solo" :path "index.js" :bundle "S"})))
|
|
(is (= #{"solo"} (t2/select-fn-set :name :model/DataApp)))
|
|
(is (=? {:synced 0 :removed 1}
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))))
|
|
(is (empty? (t2/select-fn-set :name :model/DataApp))))))
|
|
|
|
(deftest remote-sync-preserves-unpublished-data-app-drafts-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/ensure-draft! "draft-app")
|
|
(is (=? {:removed 0}
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))))
|
|
(is (true? (t2/select-one-fn :draft :model/DataApp :name "draft-app")))
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "draft-app" {:name "Draft" :path "index.js" :bundle "BUNDLE"})))
|
|
(is (false? (t2/select-one-fn :draft :model/DataApp :name "draft-app")))
|
|
(is (=? {:removed 1}
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))))
|
|
(is (not (t2/exists? :model/DataApp :name "draft-app")))))
|
|
|
|
(deftest repository-claims-a-draft-before-a-successful-import-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/ensure-draft! "draft-app")
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/draft-app/data_app.yaml" "name: Draft\n"}))
|
|
(is (false? (t2/select-one-fn :draft :model/DataApp :name "draft-app")))
|
|
(is (=? {:removed 1}
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))))
|
|
(is (not (t2/exists? :model/DataApp :name "draft-app")))))
|
|
|
|
(deftest concurrent-draft-creation-is-safe-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [drafts (doall (repeatedly 2 #(future (data-app.sync/ensure-draft! "draft-app"))))]
|
|
(doseq [draft drafts]
|
|
@draft)
|
|
(is (= 1 (t2/count :model/DataApp :name "draft-app"))))))
|
|
|
|
(deftest remote-sync-prunes-never-successful-repository-apps-test
|
|
(mt/with-model-cleanup [:model/DataApp]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/broken/data_app.yaml" "name: Broken\npath: missing.js\n"}))
|
|
(is (false? (t2/select-one-fn :draft :model/DataApp :name "broken")))
|
|
(is (=? {:removed 1}
|
|
(data-app.sync/import-from-snapshot! (snapshot {}))))
|
|
(is (not (t2/exists? :model/DataApp :name "broken")))))
|
|
|
|
(deftest a-broken-config-does-not-prune-the-existing-app-test
|
|
(testing "a directory that still exists but whose data_app.yaml is now broken keeps the app (as a sync_error), it is not pruned"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "app" {:name "App" :path "index.js" :bundle "GOOD"})))
|
|
(is (= "GOOD" (String. ^bytes (:bundle (t2/select-one :model/DataApp :name "app")) "UTF-8")))
|
|
;; Same directory, but its config no longer parses. The dir is still present,
|
|
;; so the app is kept — not treated as a removal — and its cached bundle stays.
|
|
(let [result (data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/app/data_app.yaml" "name: App\n" ; missing required "path"
|
|
"data_apps/app/index.js" "GOOD"}))]
|
|
(is (=? {:removed 0} result) "the app is not pruned")
|
|
(is (= 1 (count (:config-errors result))))
|
|
(is (=? {:changed 1} result)
|
|
"marking the app failed counts as a change, so the pull isn't reported as a no-op"))
|
|
(let [app (t2/select-one :model/DataApp :name "app")]
|
|
(is (some? app) "the app survives a transiently broken config")
|
|
(is (= "GOOD" (String. ^bytes (:bundle app) "UTF-8"))
|
|
"its last-good cached bundle is retained")
|
|
(is (str/includes? (:sync_error app) "path")
|
|
"the parse failure is recorded on the row, so the UI shows it as failed rather than freshly synced")))))
|
|
|
|
(deftest a-broken-config-for-a-brand-new-app-materializes-nothing-test
|
|
(testing "an app whose config never parsed has no row to mark — it simply isn't materialized"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [result (data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/newbie/data_app.yaml" "name: Newbie\n" ; missing required "path"
|
|
"data_apps/newbie/index.js" "X"}))]
|
|
(is (= 1 (count (:config-errors result))))
|
|
(is (=? {:synced 0 :changed 0 :removed 0} result))
|
|
(is (empty? (t2/select-fn-set :name :model/DataApp)))))))
|
|
|
|
(defn- oversized-bundle ^String []
|
|
(.repeat "a" (int (inc data-app.sync/max-bundle-bytes))))
|
|
|
|
(deftest oversized-bundle-is-rejected-test
|
|
(testing "a bundle over the size cap is rejected with a sync_error, no bundle cached"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "big" {:name "Big" :path "index.js"
|
|
:bundle (oversized-bundle)})))
|
|
(let [app (t2/select-one :model/DataApp :name "big")]
|
|
(is (some? app) "the app still appears in the list")
|
|
(is (nil? (:bundle app)) "no oversized bundle was cached")
|
|
(is (str/includes? (:sync_error app) "MiB"))))))
|
|
|
|
(deftest oversized-resync-keeps-the-previous-bundle-test
|
|
(testing "an oversized re-sync sets sync_error but keeps the last good bundle"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "app" {:name "App" :path "index.js" :bundle "GOOD"})))
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (app-files "app" {:name "App" :path "index.js"
|
|
:bundle (oversized-bundle)})))
|
|
(let [app (t2/select-one :model/DataApp :name "app")]
|
|
(is (= "GOOD" (String. ^bytes (:bundle app) "UTF-8"))
|
|
"the previously cached bundle is retained")
|
|
(is (str/includes? (:sync_error app) "MiB"))))))
|
|
|
|
(deftest a-directory-without-a-config-is-not-an-app-test
|
|
(testing "a data_apps/<dir> that ships a bundle but no data_app.yaml is not discovered — no app, no error"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [result (data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/orphan/index.js" "BUNDLE"}))]
|
|
(is (=? {:synced 0 :changed 0 :config-errors []} result))
|
|
(is (empty? (t2/select-fn-set :name :model/DataApp)))))))
|
|
|
|
(deftest an-unreadable-config-is-a-config-error-test
|
|
(testing "a data_app.yaml the snapshot lists but can't read is isolated as a config-error, not a crash"
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
;; the config is listed in the tree, but reading its blob yields nothing
|
|
(let [result (data-app.sync/import-from-snapshot!
|
|
(snapshot {"data_apps/ghost/data_app.yaml" nil}))]
|
|
(is (= 1 (count (:config-errors result))))
|
|
(is (str/includes? (first (:config-errors result)) "data_apps/ghost/data_app.yaml"))
|
|
(is (empty? (t2/select-fn-set :name :model/DataApp)))))))
|
|
|
|
(deftest resource-failure-rolls-back-only-that-app-test
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [files (merge (app-files "broken" {:name "Broken" :path "index.js" :bundle "OLD"})
|
|
(app-files "working" {:name "Working" :path "index.js" :bundle "OLD"}))
|
|
ensure-resources! data-app.resources/ensure-resources!]
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
(let [collection-id (t2/select-one-fn :resource_collection_id :model/DataApp :name "broken")
|
|
old-name (t2/select-one-fn :name :model/Collection :id collection-id)]
|
|
; inject an error into `ensure-resources!` for the "broken" app
|
|
(with-redefs [data-app.resources/ensure-resources!
|
|
(fn [app]
|
|
(ensure-resources! app)
|
|
(when (= "broken" (:name app))
|
|
(t2/update! :model/Collection :id collection-id {:name "Partial update"})
|
|
(throw (ex-info "Resource provisioning failed." {}))))]
|
|
(data-app.sync/import-from-snapshot!
|
|
(snapshot (assoc files "data_apps/broken/index.js" "NEW" "data_apps/working/index.js" "NEW"))))
|
|
; the "broken" app bundle with sync error should be rolled back to "OLD"
|
|
(is (= "OLD" (String. ^bytes (t2/select-one-fn :bundle :model/DataApp :name "broken") "UTF-8")))
|
|
; the collection name should be rolled back to the old name
|
|
(is (= old-name (t2/select-one-fn :name :model/Collection :id collection-id)))
|
|
; the thrown exception in ensure-resources! should be reported in the sync error
|
|
(is (= "Resource provisioning failed." (t2/select-one-fn :sync_error :model/DataApp :name "broken")))
|
|
; the "working" app without sync errors should not be rolled back to "OLD"
|
|
(is (= "NEW" (String. ^bytes (t2/select-one-fn :bundle :model/DataApp :name "working") "UTF-8")))))))
|
|
|
|
(deftest pruning-failure-does-not-roll-back-synced-apps-test
|
|
(mt/with-premium-features #{:data-apps-preview}
|
|
(mt/with-model-cleanup [:model/DataApp :model/Collection :model/PermissionsGroup]
|
|
(let [working-files (app-files "working" {:name "Working" :path "index.js" :bundle "OLD"})
|
|
files (merge working-files (app-files "removed" {:name "Removed" :path "index.js" :bundle "OLD"}))
|
|
prune! data-apps.db/delete-data-apps-not-named!]
|
|
(data-app.sync/import-from-snapshot! (snapshot files))
|
|
; inject an error into deleting data apps
|
|
(with-redefs [data-apps.db/delete-data-apps-not-named!
|
|
(fn [slugs]
|
|
(prune! slugs)
|
|
(throw (ex-info "Pruning failed." {})))]
|
|
; sync should report the error from pruning step
|
|
(is (=? {:synced 1 :changed 1 :removed 0 :pruning-error "Pruning failed."}
|
|
(data-app.sync/sync-from-snapshot!
|
|
(snapshot (assoc working-files "data_apps/working/index.js" "NEW"))))))
|
|
; a pruning failure should not roll back the updated app's bundle
|
|
(is (= "NEW" (String. ^bytes (t2/select-one-fn :bundle :model/DataApp :name "working") "UTF-8")))
|
|
(is (t2/exists? :model/DataApp :name "removed"))))))
|