🐛 Fix auto-link libraries: permission check, default selection, and tests

Backend:
- Add edit-permission check in auto-link-libraries before creating
  file_library_rel rows, matching the manual link RPC behavior
- Fix 3 broken tests by removing source library before import to
  simulate cross-environment scenario
- Add test for permission check (viewer vs owner auto-link)
- Add test for used-by filtering (only linked files get rels)

Frontend:
- Fix library resolution default selection: pre-select first candidate
  so confirming without interaction actually links libraries
- Remove unused file-ids prop from library-resolution* component
- Remove unused translation keys (library-link-error, library-placeholder)
This commit is contained in:
Andrey Antukh 2026-06-17 05:58:59 +00:00
parent 24fa844558
commit 5628efdfad
5 changed files with 691 additions and 525 deletions

View File

@ -931,24 +931,29 @@
"Auto-link imported files to libraries that have exactly one candidate
match. Returns a vector of {:id old-lib-id :name string :new-id uuid}
for each auto-linked library."
[{:keys [::db/conn ::manifest ::bfc/timestamp] :as cfg} resolution file-ids]
[{:keys [::db/conn ::manifest ::bfc/timestamp ::bfc/profile-id] :as cfg} resolution file-ids]
(let [external-libs (:external-libraries manifest)]
(reduce (fn [linked {:keys [id name used-by] :as ext-lib}]
(let [candidates (get resolution id)]
(if (= 1 (count candidates))
(let [new-lib-id (:id (first candidates))
;; Link only files that actually used this library
relevant-file-ids (if (seq used-by)
(let [used-set (set (map bfc/lookup-index used-by))]
(filterv used-set file-ids))
file-ids)]
(doseq [fid relevant-file-ids]
(let [rel-params {:file-id fid
:library-file-id new-lib-id}]
(db/insert! conn :file-library-rel rel-params
::db/on-conflict-do-nothing? true)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
(conj linked {:id id :name name :new-id new-lib-id}))
perms (bfc/get-file-permissions conn profile-id new-lib-id)]
;; Only auto-link when the importer has edit permission
;; on the matched library, matching the manual link RPC.
(if (:can-edit perms)
(let [;; Link only files that actually used this library
relevant-file-ids (if (seq used-by)
(let [used-set (set (map bfc/lookup-index used-by))]
(filterv used-set file-ids))
file-ids)]
(doseq [fid relevant-file-ids]
(let [rel-params {:file-id fid
:library-file-id new-lib-id}]
(db/insert! conn :file-library-rel rel-params
::db/on-conflict-do-nothing? true)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
(conj linked {:id id :name name :new-id new-lib-id}))
linked))
linked)))
[]
external-libs)))

View File

@ -12,6 +12,7 @@
[app.common.features :as cfeat]
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.db :as db]
@ -178,6 +179,12 @@
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; Remove the source library to simulate a cross-environment import
;; where the original library does not exist in the target team.
(db/update! th/*system* :file
{:deleted-at (ct/now)}
{:id (:id library)})
;; Now create a new shared library with the same name in the same team
;; (simulating the library existing in the target environment)
(let [library2 (th/create-file* 3 {:profile-id (:id profile)
@ -229,6 +236,12 @@
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; Remove the source library to simulate a cross-environment import
;; where no matching library exists in the target team.
(db/update! th/*system* :file
{:deleted-at (ct/now)}
{:id (:id library)})
;; Import without any matching library in the team
(let [result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
@ -267,6 +280,12 @@
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; Remove the source library to simulate a cross-environment import
;; where the original library does not exist in the target team.
(db/update! th/*system* :file
{:deleted-at (ct/now)}
{:id (:id library)})
;; Create TWO shared libraries with the same name
(let [library2 (th/create-file* 3 {:profile-id (:id profile)
:project-id (:default-project-id profile)
@ -299,3 +318,131 @@
(let [rels (db/query th/*system* :file-library-rel
{:library-file-id (:id library3)})]
(t/is (= 0 (count rels))))))))
(t/deftest import-auto-link-respects-library-permissions
(let [owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
viewer (th/create-profile* 2)
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id viewer)
:role :viewer})
library (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)
:is-shared true
:name "Icons Library"})
file (th/create-file* 2 {:profile-id (:id owner)
:project-id (:default-project-id owner)
:is-shared false})]
;; Link file to library
(db/insert! th/*system* :file-library-rel
{:file-id (:id file)
:library-file-id (:id library)})
;; Export without including libraries
(let [output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; Remove the source library and recreate a matching one owned by owner
(db/update! th/*system* :file
{:deleted-at (ct/now)}
{:id (:id library)})
;; Create a project in the team for the matched library and import.
(let [project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
library2 (th/create-file* 3 {:profile-id (:id owner)
:project-id (:id project)
:is-shared true
:name "Icons Library"})
result (-> th/*system*
(assoc ::bfc/project-id (:id project))
(assoc ::bfc/profile-id (:id viewer))
(assoc ::bfc/team-id (:id team))
(assoc ::bfc/input output)
(v3/import-files!))]
;; Auto-link must be skipped because viewer cannot edit the library
(t/is (= [] (:auto-linked result)))
;; No file-library-rel should have been created
(let [rels (db/query th/*system* :file-library-rel
{:library-file-id (:id library2)})]
(t/is (= 0 (count rels))))
;; Control: the same import performed by the owner (who has edit
;; permission on the library) should auto-link.
(let [result (-> th/*system*
(assoc ::bfc/project-id (:id project))
(assoc ::bfc/profile-id (:id owner))
(assoc ::bfc/team-id (:id team))
(assoc ::bfc/input output)
(v3/import-files!))]
(t/is (= 1 (count (:auto-linked result))))
(t/is (= (:id library2) (:new-id (first (:auto-linked result)))))
(let [rels (db/query th/*system* :file-library-rel
{:library-file-id (:id library2)})]
(t/is (= 1 (count rels)))))))))
(t/deftest import-auto-link-only-files-that-used-library
(let [profile (th/create-profile* 1)
library (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true
:name "Icons Library"})
file1 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
file2 (th/create-file* 3 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})]
;; Only file1 uses the library
(db/insert! th/*system* :file-library-rel
{:file-id (:id file1)
:library-file-id (:id library)})
;; Export both files without including libraries
(let [output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file1) (:id file2)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; Remove the source library and recreate a matching one
(db/update! th/*system* :file
{:deleted-at (ct/now)}
{:id (:id library)})
(let [library2 (th/create-file* 4 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true
:name "Icons Library"})
result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/team-id (:default-team-id profile))
(assoc ::bfc/input output)
(v3/import-files!))]
;; The library should be auto-linked
(t/is (= 1 (count (:auto-linked result))))
(t/is (= (:id library2) (:new-id (first (:auto-linked result)))))
(t/is (= {} (:library-candidates result)))
;; But only one file-library-rel should exist (for file1)
(let [rels (db/query th/*system* :file-library-rel
{:library-file-id (:id library2)})]
(t/is (= 1 (count rels))))))))

View File

@ -346,33 +346,41 @@
[{:keys [resolution selections*]}]
(let [external-libs (:external-libs resolution)
library-candidates (:library-candidates resolution)
selections (deref selections*)
selections (deref selections*)]
on-select
(mf/use-fn
(mf/deps selections*)
(fn [old-lib-id candidate-id]
(swap! selections* assoc old-lib-id candidate-id)))]
;; Pre-select the first candidate for each unresolved library so that
;; confirming without interaction still links the default choice.
(mf/with-effect []
(doseq [[old-lib-id candidates] library-candidates]
(when-not (contains? @selections* old-lib-id)
(when-let [first-candidate (first candidates)]
(swap! selections* assoc old-lib-id (:id first-candidate))))))
(when (seq library-candidates)
[:div {:class (stl/css :library-resolution)}
[:p {:class (stl/css :library-resolution-message)}
(tr "dashboard.import.resolve-libraries")]
(let [on-select
(mf/use-fn
(mf/deps selections*)
(fn [old-lib-id candidate-id]
(swap! selections* assoc old-lib-id candidate-id)))]
(for [{:keys [id name]} external-libs]
(let [candidates (get library-candidates id)
options (mapv (fn [c]
{:id (str (:id c))
:label (:name c)})
candidates)
selected (get selections id)]
[:div {:class (stl/css :library-resolution-item)
:key (dm/str id)}
[:div {:class (stl/css :library-resolution-item-name)}
name]
[:> select* {:options options
:default-selected (or (some-> selected str) "")
:on-change (partial on-select id)}]]))])))
(when (seq library-candidates)
[:div {:class (stl/css :library-resolution)}
[:p {:class (stl/css :library-resolution-message)}
(tr "dashboard.import.resolve-libraries")]
(for [{:keys [id name]} external-libs]
(let [candidates (get library-candidates id)
options (mapv (fn [c]
{:id (str (:id c))
:label (:name c)})
candidates)
selected (get selections id)]
[:div {:class (stl/css :library-resolution-item)
:key (dm/str id)}
[:div {:class (stl/css :library-resolution-item-name)}
name]
[:> select* {:options options
:default-selected (or (some-> selected str) "")
:on-change (partial on-select id)}]]))]))))
(mf/defc import-dialog
{::mf/register modal/components
@ -586,8 +594,7 @@
{:level :success
:content (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))}])
[:> library-resolution* {:resolution library-resolution
:selections* library-selections*
:file-ids (:file-ids library-resolution)}]])
:selections* library-selections*}]])
(if (or (= :import-error status) (and (= :analyze status) errors?))
[:div {:class (stl/css :import-error-disclaimer)}

File diff suppressed because it is too large Load Diff

View File

@ -756,12 +756,6 @@ msgstr[1] "%s bibliotecas fueron vinculadas automáticamente por nombre."
msgid "dashboard.import.resolve-libraries"
msgstr "Algunas bibliotecas no pudieron vincularse automáticamente. Selecciona la biblioteca correcta para cada una:"
msgid "dashboard.import.library-placeholder"
msgstr "Selecciona una biblioteca…"
msgid "dashboard.import.library-link-error"
msgstr "No se pudo vincular la biblioteca"
msgid "dashboard.import.confirm-library-links"
msgstr "Confirmar vínculos de biblioteca"