From 83db3cdb4766e18920d38d0dd5bb9a0fba6c6569 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Wed, 26 Aug 2026 18:22:53 +0900 Subject: [PATCH 01/13] fix(weko-records-ui)!: add auth and permission checks to storage APIs - Add @login_required to get_bucket_list, copy_bucket, get_file_place and replace_file - Add _validate_storage_api_request(), which checks the feature flag, record ownership, base recid and request parameters in that order - Verify bucket_id, file_name, new_bucket_id and new_version_id belong to the record identified by pid - Return 403 with a single shared message; log the reason separately - Hoist shared parameters in replace_file so both branches are checked - Register the new msgid in the translation catalogs Co-Authored-By: Claude Opus 5 (1M context) --- .../translations/en/LC_MESSAGES/messages.mo | Bin 17162 -> 17580 bytes .../translations/en/LC_MESSAGES/messages.po | 168 ++++++++--------- .../translations/ja/LC_MESSAGES/messages.mo | Bin 18732 -> 18887 bytes .../translations/ja/LC_MESSAGES/messages.po | 168 ++++++++--------- .../weko_records_ui/translations/messages.pot | 172 +++++++++--------- .../weko-records-ui/weko_records_ui/views.py | 115 +++++++++++- 6 files changed, 367 insertions(+), 256 deletions(-) diff --git a/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.mo b/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.mo index ceaa2b7c8762862cb37865796a55c62669dd98fe..98a693579aa473dc0581d3c49f7eef4b6cf61858 100644 GIT binary patch delta 4557 zcmb`}dvKM-8OQNGxe$_IQj!44LE;HXxS9)rTtI@kKui&>APibUoe)kWCLwUni3X&+ z9Ys+)R`d)obc#kH6defI(^wp}bhwocAr(4YtQ5gsKwG8Eh-6xjet&s)rhnPLn#ts| zyYIfc`|Lh@PF{G{v+H?}b3P?zqv6NnG3Iv68LZlWfA0K_F>9&j;T^aO>1uj$5cXjl zp2B$i6Y^^=@h1WMaRNpUWem*6bgaV|V;s}&7R+kYfDR7FXE71C+V(fGi27bE$3G*# zCM(6Aa5AP+pNX3AA&kYRQR6(1TIfd90=Hr+^P6tlZ~$46`2%+2S=36`Gpg)F4fHN* z;=|~}Gnj?_I0C(C#uQ@_PQzt*FK)w1yn;12X&CLyZ#pQ{;US!dS%g0yL-;WM4y!Sn z(QENhoQ``@3;6BT6HF7#;0<$OwDo{x@2Q}eh)I=dvr0Y-!P`aAs;wDRat zBp9ZmlBpEcJ{y(Y-^bCo1~u?&$nl%)w*CP|Q9poM_+eCTeT>SLzn~U+1Jy5v#9#*< z6GuT?nvR+<$JVE~9ZUr(^0}x17om=5DJt0lsI6XwI>P5r{UWIG-bd|JFDj6es6fBM zk-GosWLPN;Q&1~!L9O&D)IcvF`DVh%;>~W^g>ZrD& z7WRhqP1J(+qCRcSehNy46UeVQ#~&TZH?}>2gV#XAQ2o59$Z}Czn`fU-MvYfy>$C0i z@1goHKxO@6R6v2z?Ee@F4h^}u19jbcQCqEpdJrzeF9?Ur{^oZ`9d)bKD)6 zg)^u(;yCO=_3J|g_yubGPUOdO13P~#S0G!|nqPPMk-bm~qQ1)a&Ks1;vFWq15|cLAeNSvwIm@hsfw zA$nX(J(f&U@~uOf&34p6cc2!u8{@GDwKMxs3;Pf`GRK^zkVk{(PIux0R7BO*TGW8| zp%yS76S3L0KY{uPUyJ4VHY$)Ss0lp`u7FZdIWh*dfVr5c`@fKaR@#7ip&9kU8e89h zTF~p*jXO}+bV0uBa@2y>qb3fc54%vwb_55$Em22c3f%t-N^s!&e#q(6;u*cO>+M)*o@WGuVO70vkf}awWx)>hbed*Z^H|yo%|9N;5Bp{>dypW^AsmMNs2oTtadXIvns6d&qAEDXcGFQYyI zH=!231(jQSaD?vvJ_=gthp3KcQP=Qe)Rz7OwUB;WA57SKo`#A%7d2o3>WIov$yS5f z>ibYzU5D!T1Zun=VXW@|1`3K~6Y4u)A1Z0iAwipd)XEE|xeKj94Rk+d;6l`w$x2Mc zm(YvbP=Wm#wV)%Yg&()==P`--&EF_!plj&G z&!Zy#43&ge?epuX@ow095}(t0o?6cS>xB#&l=WGth)Qq_R%0$chPrMWa1TdV{f@~Z|eaYSIzdHVy21WQ5YJy%=ThqrJf*0DMzX$R`;pF-_K7?tc@4u$y?cH%vF({`9uO(!Y)waeo_6+e6Fz%R@~K^&$UKUwhCW$PXVceKAs+xivO^N&CPEZGrG7<+q3X zva`zC+5-)KUt?2?-xq9J?Pt&izkg}a#~{9zrq-sAudyxQYY4OjgL#bgcoV%F+E%QJ zT+aS|bU40zXxkdnN@vb>@a-=xxM#gTuGYmfS$ q5#+{%ua}(pwkRUUOH&8^eoeK~d2>#2rBdLGv$#oh@_oL)-)XM*Y+Ubg zeuxg)DF`aC8wh%nvYs|DSB}YCgDzu#RHg)-(x;T z^*3e)mSQo!gj2B7wkHiRrh@g&V-(8pFqY%sft(g=aS|TIY1ofd3h+VHMqWhrYW83R zet?Q#2eJoq9Ch;Zw*PnQb<{2Q;dD+ag(wPTHr|>jGK`;Oe<>K zAtXJ_3DgG8;2``NhhR{=F&x?qv*w}>P#VwuYk@g5Xo5=Agv+d}QAxJ}m7GneyKP4$ z*J0H6zs3aYM1p0mqQ*tA3(XsY8aEvEy%f}eGY1j>3<@PQD0x<(PP)SmXu(YCA0fA6 zE~9cGl+~0>F{lNTQ45Vnopc6jfqBSRObu!S8&L<`gc`Tip`Zy`Fb@x+PI?QqU?LT@ zXP~~g#JUo7!ZoNnUXMCiqwU{{T5z}R-(%}<+x~s1@y;g{wDZF_96M1-bhz*^J+cO$_yhcK7*%{dC%aRQm9ou;EE z%0u$VlpuRC6{yGRanzkQqQ<|0+R!_wlf7r#J5U=uftu$u>U-Bw>kaU7^$vwQC@7RA zYQWh@^i3^><0jPO@~X83*|XV?%wWWHH8@t!~0BS>XvFAmGj*?&v z1^&!4d?>r$Lbbn-nyB5@J5VS73KiNe+kX}{VYjXSWBYHS#s`x)%Jy*7fs$|}rVbY9+T`IC<8TLIqE>Q$Plv{ zncD13CjOeJ-FBSAY1Bj6RSw>R6}SPFH0QA=DcQd6It+F41nh-AEXOpQitB9uG3-tK z6e>A?M7=@(awsUPBSyK&k$@r8b5OZ30kvQ`2ICwI#Y$9?R--0fjzRc1W@8-|;Jc{M zUq)>-gfJeajwC*T{{lVqrc{y|L`Hr8Ee2x{T! z*cWG_HaHtKz7jQlxvf8q!Fv9;QfQ%LJL)mKo9)VlsEHiZ!q1==w_y@~jJm_qsF44S z`IwsO{_ZcvV(R;F3SP7Ag?G9+wGqqo{C87O(&VSPzx$hT67^qj8v4@RyIhIdNC5SU zK8O+c9V&u9p-y-Kb#f2kQ~zz&SnNZ61hN;CicTbjhbfTwW(n#pH>2+871RVfP>)dy z>cmG;n&1lRvAl*#-k2MrDy_&gM#9vwZJPkVOM|Qvw zWKPqGdf`NJ!ODeXR5GQZ7A!_B^Z@q9MW_Wk*+L`CvfWCM=5Y8yhv zxdWn6CmxQP$cMU`98{7`Mum6=D#Q<=#;rh|s16mOM$`dbK)v(ZP`T2HENQOd06qVy z#92F?jGCwv<8c-$>uWFupG7UW6E*%r)P~woCp%)>yHFcEkMZ~?>U$CSZiG^BAoW6w z)AK*eHY`Quz&Z@aU8u*U)%q#w$LdSe({Tm87(L$o-Oof_(OlHVs;t$h4b`INTZ_s8 zKROE0b_%+a{kG#MYNBs#y$f~XZd7P5+5W#!6JEFVaDH*rAB7qpi^}$)r~_r;NG!$? zxTt{m>oIGjL81N(OYt-oqmLIqFF{j-O0FHK2(+OWzs3~2YTJhvx_4fPgK3|KI%yp$ z2kKGD{1y(u#GQ35@AC#}jxhYG!EcBUM$k3zjWQsa;T6 zvvlF&+Q%MQzPM)T=s1n=< mbZ>fAep=wn=oP*GZ@RI;{xiAX29A%54zbfk1^Zhj-TW8GQ_QLW diff --git a/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.po b/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.po index e10a237902..e9df92e690 100644 --- a/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.po +++ b/modules/weko-records-ui/weko_records_ui/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: weko-records-ui 0.1.0.dev20170000\n" "Report-Msgid-Bugs-To: wekosoftware@nii.ac.jp\n" -"POT-Creation-Date: 2025-12-24 10:03+0900\n" +"POT-Creation-Date: 2026-08-26 17:56+0900\n" "PO-Revision-Date: 2018-04-12 18:06+0900\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" -#: tests/test_utils.py:717 weko_records_ui/api.py:678 weko_records_ui/fd.py:650 +#: tests/test_utils.py:717 weko_records_ui/api.py:691 weko_records_ui/fd.py:650 #: weko_records_ui/fd.py:728 weko_records_ui/utils.py:1214 msgid "Unexpected error occurred." msgstr "" @@ -28,7 +28,7 @@ msgstr "" msgid "Failed to send mail." msgstr "" -#: tests/test_views.py:1342 weko_records_ui/views.py:1261 +#: tests/test_views.py:1342 weko_records_ui/views.py:1264 msgid "MSG_WEKO_RECORDS_UI_IS_EDITING_TRUE" msgstr "Cannot delete because it is being edited." @@ -63,51 +63,51 @@ msgstr "" msgid "Bulk Update" msgstr "" -#: weko_records_ui/api.py:220 +#: weko_records_ui/api.py:221 msgid "Not authenticated user." msgstr "" -#: weko_records_ui/api.py:224 weko_records_ui/api.py:227 -#: weko_records_ui/api.py:289 +#: weko_records_ui/api.py:225 weko_records_ui/api.py:228 +#: weko_records_ui/api.py:290 msgid "S3 setting none. Please check your profile." msgstr "" -#: weko_records_ui/api.py:246 +#: weko_records_ui/api.py:247 msgid "Getting Bucket List failed." msgstr "" -#: weko_records_ui/api.py:325 +#: weko_records_ui/api.py:326 msgid "Getting region failed." msgstr "" -#: weko_records_ui/api.py:363 weko_records_ui/api.py:454 +#: weko_records_ui/api.py:374 weko_records_ui/api.py:467 msgid "Uploading file failed." msgstr "" "Uploading file failed. Please make sure you have write permissions or " "that the bucket is writable." -#: weko_records_ui/api.py:403 weko_records_ui/api.py:660 +#: weko_records_ui/api.py:414 weko_records_ui/api.py:673 msgid "The source bucket or file cannot be found." msgstr "" -#: weko_records_ui/api.py:418 +#: weko_records_ui/api.py:429 msgid "The source file cannot be found." msgstr "" -#: weko_records_ui/api.py:450 +#: weko_records_ui/api.py:463 msgid "The source file size exceeds the limit for cross-service copy." msgstr "" -#: weko_records_ui/api.py:476 +#: weko_records_ui/api.py:489 msgid "Bucket already exists." msgstr "" -#: weko_records_ui/api.py:525 +#: weko_records_ui/api.py:538 msgid "Creating Bucket failed." msgstr "" -#: weko_records_ui/api.py:551 weko_records_ui/api.py:711 -#: weko_records_ui/api.py:712 +#: weko_records_ui/api.py:564 weko_records_ui/api.py:724 +#: weko_records_ui/api.py:725 msgid "Cannot update because the corresponding item is being edited." msgstr "" @@ -300,7 +300,7 @@ msgstr "" msgid "The provided token is invalid." msgstr "" -#: weko_records_ui/utils.py:2338 +#: weko_records_ui/utils.py:2338 weko_records_ui/views.py:1492 msgid "This feature is currently disabled." msgstr "" @@ -312,28 +312,32 @@ msgstr "" msgid "This URL has been deactivated." msgstr "" -#: weko_records_ui/views.py:914 +#: weko_records_ui/views.py:917 msgid "Secret URL generated successfully" msgstr "" -#: weko_records_ui/views.py:923 +#: weko_records_ui/views.py:926 msgid ", please check your email inbox" msgstr "" -#: weko_records_ui/views.py:925 +#: weko_records_ui/views.py:928 msgid "" ", but there was an error while sending the email. To use the URL, please " "refresh the page and copy it from the issued URL list" msgstr "" -#: weko_records_ui/views.py:928 +#: weko_records_ui/views.py:931 msgid "." msgstr "" -#: weko_records_ui/views.py:1158 +#: weko_records_ui/views.py:1161 msgid "PDF cover page settings have been updated." msgstr "Updated PDF cover settings" +#: weko_records_ui/views.py:1498 +msgid "You do not have permission to perform this operation." +msgstr "" + #: weko_records_ui/templates/weko_records_ui/_macros.html:47 #: weko_records_ui/templates/weko_records_ui/_macros.html:60 #: weko_records_ui/templates/weko_records_ui/_macros.html:72 @@ -507,8 +511,8 @@ msgid "Edit" msgstr "" #: weko_records_ui/templates/weko_records_ui/body_contents.html:411 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:270 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:317 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:272 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:319 msgid "Delete" msgstr "" @@ -599,201 +603,201 @@ msgid "No title" msgstr "" #: weko_records_ui/templates/weko_records_ui/file_details_contents.html:68 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:257 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:304 msgid "Action" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:132 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 msgid "Replace the file content" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:134 msgid "Copy file to open bucket" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:157 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:248 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:159 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:250 msgid "Secret URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:170 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:172 msgid "Plagarism Check" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:200 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 msgid "Link Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:204 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:209 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:215 msgid "Item has not been filled in." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:205 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 msgid "URL Expiry Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:208 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:210 msgid "Max Expiry Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:211 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 msgid "Download Limit" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:214 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:216 msgid "Max Download Count" msgstr "Max Download Limit" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:218 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:220 msgid "Create Secret URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:221 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:223 msgid "Send Email" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:251 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 msgid "Label Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:252 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:299 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 msgid "Create Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 msgid "Expiration Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:256 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:303 #, fuzzy msgid "Download Count" msgstr "Max Download Limit" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:275 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:322 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:277 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:324 msgid "Copy" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:283 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:330 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 msgid "message_del_check" msgstr "" "If you delete this URL, it will no longer be available. Are you sure you " "want to delete it?" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:284 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:331 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:286 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:333 msgid "message_del_success" msgstr "URL has been removed" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:287 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:334 msgid "message_copy_success" msgstr "URL has been copied to the clipboard" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:295 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:297 msgid "Onetime URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:298 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 msgid "User Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:338 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:367 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:340 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 msgid "Version" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:5 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:339 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:341 msgid "Stats" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:346 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 msgid "" "Copy Success. Take note of URL. This URL cannot be confirmed again once " "the screen is closed. If you have created a new bucket, please check that" " the bucket is set to public." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:347 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 msgid "Please select the same named file as the original file." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:350 msgid "File replacement successful." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:351 msgid "Replacing file failed." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:353 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:355 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Show" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:354 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:356 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Hide" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:368 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 msgid "Date Modified" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 msgid "Object File Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 msgid "File Size" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 msgid "File Hash Value" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:374 msgid "Contributor Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:394 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:396 msgid "Downloads" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:402 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:404 msgid "Plays" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:29 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:412 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:414 msgid "See details" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:453 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 msgid "Chose bucket or input creating bucket name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:457 msgid "Bucket" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:465 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:467 msgid "New Creating Bucket Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:479 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:481 msgid "Execution" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:483 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:485 msgid "Close" msgstr "" diff --git a/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.mo b/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.mo index a433d4e84f7e885a2eddd9b4c801686bc67f64e0..14b168d062d662cebeb3423da8c80ddbaab749d9 100644 GIT binary patch delta 4676 zcmZ|Sc~Dl@8OQMhvbuqa0aWyb5ET_7C@#1FYQUv~R&S*SHEEhRFRxK+ zF<9S-inghmxYPypO*JHLO&c}S$t0+`@*b1O<0EyVLi6P-A;cKX3#!{qwqHJW70d@ z3y#Guw5OpKoQI+4N6oVywb3_G8{CauSl=9RI{ty|$XK`sTTwe*%dB!QYN8KN3x9?# zyo`w$5o1g&_QOmZgITy3Ct(0{@jm8ZZddwQ-z?{17JiCTF`e+I;S!vQXK*YIV)pU) zOB{*EP#gIX*{g|SmmM$(mC1p~-po)`Rb)x1=ToseW}s@Q05#5ynzs}+?pLViUhYQz74dKB$iN0v@qCDi z@H^D~FlHTuU6E5V6Hv7<8&yoDn1C;$7H&W-xDOTaUr_U$My52)sErs8;V9Bj)W9gz z1j#rYM<73@nqOLYwbQ>H_1ssEU!x-Z59+LcKt&kBt~4$RwO~Bzeov?E>Fo^Yk9r^z zweuW&7^k3$tIFwLhN||pn1oHJiO(VBVE*N_?_n_Q`>2fvQvj;9I8@CfA{+6T;aq57 zE-IDdP^q1c+DMVpUgX^WH7cT4Q4_919n~gO(KVt{eE@Zo@1w^38#UiuRHh;+s6@U0 zQCuj}bmUmfbR3F{P&?m&O4(7=L?@8?Gan={&QpLOmpM9o+2 zw3j*eYfX`6$(+un?cXTx`UFcoUVmq+~k-!_Y;0 z29CzX*cT5v_rF91c0ZZ?YvM#!Q)Jo5TxKG&wpoBm>F=EWy*Qrsr#J)?*zGKwhN_)? zsN(z(b>^R=GSG^_cn_!HkC=~BJ$>u}4H!ztE>xB7N4+NRp{n~VszzEd4BHTnYNRb{ z!9)zf{uqt}F#?C7=FP=6n2(t_$PMj#o`IW! z$PiZ3j;6xMs#%I!XeTO=M#rNVN&6#IX3ijic+6EU^q$_tRO~UpuKJ0nNQ+UCJ&WzJ z9JBF7Y=?(X3!OxL!oP6tw_`GWxF%tHdxJ6vesPpAburP(`-L%r82 zPJ0w8f&vUB^2d=!%u|orpXyrFLdQ|Ha0*>`1GR88m8$U>s3Uk9vsmBMb1?}&!CXv8 zxBr1K8`ZxRRb;ntDo!R$713s#iQnQ_{5h}6cwB=z(hJxLyAQSlOvMhgb5Ma6phvIG zbS_jhrOtp#M=y4!zYYo3Y{m%u1odG$i#qe$sLaF>mL^U@&6|RXJlp9nLOnkZHSeNK z@~^61LWf?n*D(f27ZT%JUGiqF{(cnH82sCku>N2a2!K>0xE!7)S2!?o%s>e z7xYV1O|%(dzZGp!?cT^2(PL7%&<+QpiX#tWaWZPBzd(ID%P|@&Pz(7`&()(+z00}( zHfrM^pyoS;I;wN1g|DJAeG~iX{nxvqf&EcuG8mPrai~ZpVjnC+71wL1vp$K6Ja&}* z+z@;a0LIfl856JsyWld^hBu%#xDUg${<~Z#viDFCpF-{MpQwqhVmAyKZBNh(RRfvW z9e;+}*gWik8&OBrgnE09qcZjdDxhoF3p-?!e-{_2T)43SRReFKQq|;m47H&Xs0mM_ zB56e}{1fU3GIH!18I5{=0xF|ZP=P&-%IrMUIty~hzb347I$l8ySb=(A6{_mjp(5Cg zJ@FV)ujZm-e6IaptwJoM|5bbvzd{{V&KP^cb*SgxL_PuLZ(}_6C-i$d`qD9-@2Cd6 zgo*fj?1M*9)qfe2@fIdw>^Qrfh5De(#__lcNs2jxvoQWK`~GvNjBG<4`9TjC%D_ns z#!B^hSAQ!jhK%be+fhI4u<1hRI$zs zV!N8SEw;gEoQ5%|h)QrIHlR}WIV!asC)mYThT6b#RI%2higRxuK6-XgOmV5Z%2iZW z;VzolNg?Xu=Q4VHI}Ra0wuU$=a#teSn6&u96TTi!;?yWa9{v%EW-cdu*S zxZU!uwY*y`?-I*VHYWem1`E&i344sWz-R<~{<1xs7D@3^vl6%kn88mneS;8uQC(0>8C!jTRItsM;yfudC~1#4%Efg(_vszng`{blde$?WIcd(J)Q z{Qu{FFLnlF_C6ou|17QV3r7FN7?TN^iM?0<|H(`-rh%(09EC3-=hXrbgZ zV+LauYN33L!z$D~OHmuFMU7vL8o$2x+HcHO8tlaE#eMiXYKM<8NTo zhdFo#hhYyEVA2p{im(Ld;?J=Jk77CYPmkVz0IOKvY@@LNzr}@EIn zP#HRen&@53!apNxnQN$V@fpSp!W7g-Y&ZhPVm6i{Nia^!)ft?>B6*G*T3{P$f}N-d z_gjWh#rHm{NWVaxaUW(^k@Z78KLkf&29h*WfEqUsHSYq{xTUD)R*WG3irmAE$rwNt z(Hp2p|Ay+nia)^H$SIjTR#8oqql#()DkIBK3kOgO{t^{&J8GUo$Q0%S)JD4fG!*HV zsDWRjCP*U8Ld-=)T*Z$Te%iXf5%t_L%a2fzUO=6B1Qp>GtN&}%g4(6}<51WB0W>sG z8tQ>e)XvA^JvbFrWRF<)Yf)9-fVsE>HPN4tGB8K2>%U_x*I%GEei>DCcTpQiq#)?` zn-m&K;b2rs?WhgpS=Yr@|2))2A4N^H0(C@nsA6kCrTAIY5pF__JA#_;C@MqePyu{~ z_pp9%Ws!Abr^2RUF>1$8p?2Den&?HO?#y;12=fl=H9Lzs(<`X)@wVuO2BHE>N8KNT z+TbMAJTp-BXC9)V1wE*<-+)TxcI)~8a(w1ACgYc=x8|m065$Yx$wKyErXyXZ3crWX zppIxaDpUI`-$cK56sDmGPoQex0`g}r@uMT@&q3?{P}D>@*7X=vWcjGn7FzwYQ1g{p z*Nd$FM^NLPsA8|lCI5=3fg5A+IUJ4qQLo!MRH~Dy#0PN#mS6+s;U7?$yMf9;f;}4f z7#zp-9Mt_fR6rY1^SzD==(L^u^Q`IO25Xr+s0VV`#WE~L^}mW0coe5#D!Z-13RLav zLKWv9QD=Svm4WjZi`Q@=-oQCnJ~rCl>ZcLMjUA{eZ9{!h-$GUQDO7Qt$9Vh(RU>y$ z3#JohUmS%AXvajHh?=(m``|Pzz*#sQ>rqGG-%mq3?L<}cr>KRmqN?;3YQgmJ#{4RV ziLsvRQ@mp&%al=JTA&6Mz>}8Gpf=co%E)F^0PV=5rUM;%|GQ|Y+7l;62jrk48i%S4 z2PR<=YJqapr+N|2z$VlOsuPp33w74rxE%YjiZ)V*8n*_ukrqtR`@e~XBHz_}gZCQs zz;TQt;=dq&rfX94lbrbd=t8Bav!9PPd;+y_Giv-kRLaj|5vEbGbFmUja0`~}{r{`g zk((bas#;vY{S&C7$tK)~(TlV3BvxQ5uZYg_C#WObjA^(V2VfW#>2cIsat2i+-B$l~ z^lRXEH1uH7y?mW88xye-bu^1mXYEF1W)o`SS5Oo0vic9AGVr<8A4~X}rypwmG*mH< zM!jXl1>}DSjV0WWK2#)4sI%OPgRtH50BQqAQAPA2vUl?xs@n5;H}zgmMb%I#@@MAo zLy}DcYFr0u-Z!U^e+_((8+!0K>Wla>YKQ-@?883{%2Y0@KM#L|vrr$DR#cJh#S}bX z-9Lsyx$Z_Scn3A_!26=}IQ%q}lIf_3AHsC3MrC9TYJ!beh;29=KSyQYrgh&=r!un` z_54ayqz$MuUx%8%88vPTDsca=Y4oPp>NtQJ_%13Vr>*`jEam#2s0bXCw9a%M>dcp* zzMuhAZM=$lJAP|jccQ+8r%)R_gVcuKbkood|78vMFDiw%Pzxkai#{+4mEr8+W1^lz*RU z+Sm=u#L+XOM^%b?Yv!Xe=0pYL!%_Gm+VCxO;aSw#&zMF2wX;$h@_tmxevF!M32Gxh zMJ>D@bp!`dwb6-s?n6{&K1D@#8C85YPz!y7n(wxCJ+L_1Ke(9u>wyezsM>Q-5lq2s zydSAIv(j=WQWoYsK8V9g`2RtC0(Df!P#YdjrRw=f$Qx`baU8C*?!SgPTz^pNkDmE8 zZYZLmd|&ODgSl94UDx1pu3Iq=zrqR}Q^q#{-B^VksQ%<1Ml&)4b>@|*3@pZ2tiyWr z`e`hqaT&Ga%JOJxR-sbr!C}~ls_vK2f$bQNpWw@Q0X2TjoM_5_fy(SgRIzTxINX7K zuniYt;T{?kuDOd!)pWvAO1-F}JA+#IDym3tp^7p+RG6|PCe)F-IANHxy4vINddGQO z%iOigYdlXneKqdd@uB#Pk#V8I?6UY^*nacTYNyX>tMS@u-9DRhwX5Wm2R)g_Oq34TeZ9QL62)ijo0T=kE_OGt8;s6 zw3autZ}caz!EF^q!T*fwNiJ~Y=Q$?l+w$)%$_w?3Z;B0uC(aM{q|OY@b=*w|jx6s# z=z4fV_s-v6+SAg#sr`DmDKxVnH9lCFof%p??Wu&|+xEwUM`pJ@7FoMKvNjxPJQ4|X zT->mmv5~-UB7v4j;BX|cHPRSlnv1WsU0&PXy=i;zO@2Er9(w0e;O$7DxqHjO%gy^E jffpi;&5=M`B(N?L*wg#FJ<=G^vWv@-`i5SraK!u%+loVY diff --git a/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.po b/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.po index 0fc92c5d76..1de52aeb33 100644 --- a/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.po +++ b/modules/weko-records-ui/weko_records_ui/translations/ja/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: weko-records-ui 0.1.0.dev20170000\n" "Report-Msgid-Bugs-To: wekosoftware@nii.ac.jp\n" -"POT-Creation-Date: 2025-12-24 10:03+0900\n" +"POT-Creation-Date: 2026-08-26 17:56+0900\n" "PO-Revision-Date: 2021-02-02 03:25+0000\n" "Last-Translator: FULL NAME \n" "Language: ja\n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" -#: tests/test_utils.py:717 weko_records_ui/api.py:678 weko_records_ui/fd.py:650 +#: tests/test_utils.py:717 weko_records_ui/api.py:691 weko_records_ui/fd.py:650 #: weko_records_ui/fd.py:728 weko_records_ui/utils.py:1214 msgid "Unexpected error occurred." msgstr "予期しないエラーが発生しました" @@ -28,7 +28,7 @@ msgstr "予期しないエラーが発生しました" msgid "Failed to send mail." msgstr "" -#: tests/test_views.py:1342 weko_records_ui/views.py:1261 +#: tests/test_views.py:1342 weko_records_ui/views.py:1264 msgid "MSG_WEKO_RECORDS_UI_IS_EDITING_TRUE" msgstr "該当アイテムは編集中のため、削除できません。" @@ -62,50 +62,50 @@ msgstr "" msgid "Bulk Update" msgstr "" -#: weko_records_ui/api.py:220 +#: weko_records_ui/api.py:221 msgid "Not authenticated user." msgstr "" -#: weko_records_ui/api.py:224 weko_records_ui/api.py:227 -#: weko_records_ui/api.py:289 +#: weko_records_ui/api.py:225 weko_records_ui/api.py:228 +#: weko_records_ui/api.py:290 msgid "S3 setting none. Please check your profile." msgstr "S3に関する設定がありません。あなたのプロフィールを確認してください。" -#: weko_records_ui/api.py:246 +#: weko_records_ui/api.py:247 msgid "Getting Bucket List failed." msgstr "バケットリストの取得に失敗しました。" -#: weko_records_ui/api.py:325 +#: weko_records_ui/api.py:326 msgid "Getting region failed." msgstr "リージョンの取得に失敗しました。" -#: weko_records_ui/api.py:363 weko_records_ui/api.py:454 +#: weko_records_ui/api.py:374 weko_records_ui/api.py:467 msgid "Uploading file failed." msgstr "ファイルのアップロードに失敗しました。書き込み権限や書き込み可能なバケットであることを確認してください。" -#: weko_records_ui/api.py:403 weko_records_ui/api.py:660 +#: weko_records_ui/api.py:414 weko_records_ui/api.py:673 #, fuzzy msgid "The source bucket or file cannot be found." msgstr "コピー元のファイル、バケットが見つかりません。" -#: weko_records_ui/api.py:418 +#: weko_records_ui/api.py:429 msgid "The source file cannot be found." msgstr "コピー元のファイルが見つかりません。" -#: weko_records_ui/api.py:450 +#: weko_records_ui/api.py:463 msgid "The source file size exceeds the limit for cross-service copy." msgstr "S3互換サービス間でファイルコピー可能なサイズを超過しています" -#: weko_records_ui/api.py:476 +#: weko_records_ui/api.py:489 msgid "Bucket already exists." msgstr "指定されたバケットはすでに存在しています。" -#: weko_records_ui/api.py:525 +#: weko_records_ui/api.py:538 msgid "Creating Bucket failed." msgstr "バケットの作成に失敗しました。" -#: weko_records_ui/api.py:551 weko_records_ui/api.py:711 -#: weko_records_ui/api.py:712 +#: weko_records_ui/api.py:564 weko_records_ui/api.py:724 +#: weko_records_ui/api.py:725 msgid "Cannot update because the corresponding item is being edited." msgstr "該当アイテムが編集中のため更新できません。" @@ -298,7 +298,7 @@ msgstr "" msgid "The provided token is invalid." msgstr "トークンが無効です。" -#: weko_records_ui/utils.py:2338 +#: weko_records_ui/utils.py:2338 weko_records_ui/views.py:1492 msgid "This feature is currently disabled." msgstr "この機能は現在ご利用頂けません。" @@ -310,28 +310,32 @@ msgstr "このファイルは現在ダウンロードできません。" msgid "This URL has been deactivated." msgstr "このURLは削除されました。" -#: weko_records_ui/views.py:914 +#: weko_records_ui/views.py:917 msgid "Secret URL generated successfully" msgstr "シークレットURLの作成に成功しました" -#: weko_records_ui/views.py:923 +#: weko_records_ui/views.py:926 msgid ", please check your email inbox" msgstr "。メールをご確認ください" -#: weko_records_ui/views.py:925 +#: weko_records_ui/views.py:928 msgid "" ", but there was an error while sending the email. To use the URL, please " "refresh the page and copy it from the issued URL list" msgstr "が、メール送信エラーが発生しました。ページを更新し、URL一覧表からご利用ください" -#: weko_records_ui/views.py:928 +#: weko_records_ui/views.py:931 msgid "." msgstr "。" -#: weko_records_ui/views.py:1158 +#: weko_records_ui/views.py:1161 msgid "PDF cover page settings have been updated." msgstr "" +#: weko_records_ui/views.py:1498 +msgid "You do not have permission to perform this operation." +msgstr "この操作を行う権限がありません。" + #: weko_records_ui/templates/weko_records_ui/_macros.html:47 #: weko_records_ui/templates/weko_records_ui/_macros.html:60 #: weko_records_ui/templates/weko_records_ui/_macros.html:72 @@ -503,8 +507,8 @@ msgid "Edit" msgstr "編集" #: weko_records_ui/templates/weko_records_ui/body_contents.html:411 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:270 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:317 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:272 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:319 msgid "Delete" msgstr "削除" @@ -595,198 +599,198 @@ msgid "No title" msgstr "" #: weko_records_ui/templates/weko_records_ui/file_details_contents.html:68 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:257 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:304 msgid "Action" msgstr "アクション" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:132 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 msgid "Replace the file content" msgstr "ファイルを置き換え" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:134 msgid "Copy file to open bucket" msgstr "公開バケットにファイルをコピー" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:157 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:248 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:159 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:250 msgid "Secret URL" msgstr "シークレットURL" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:170 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:172 msgid "Plagarism Check" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:200 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 msgid "Link Name" msgstr "リンク名" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:204 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:209 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:215 msgid "Item has not been filled in." msgstr "項目が未入力です" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:205 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 msgid "URL Expiry Date" msgstr "URL有効期限" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:208 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:210 msgid "Max Expiry Date" msgstr "有効期限上限" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:211 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 msgid "Download Limit" msgstr "ダウンロード回数" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:214 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:216 msgid "Max Download Count" msgstr "ダウンロード回数上限" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:218 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:220 msgid "Create Secret URL" msgstr "シークレットURL作成" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:221 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:223 msgid "Send Email" msgstr "メール通知" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:251 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 msgid "Label Name" msgstr "リンク名" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:252 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:299 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 msgid "Create Date" msgstr "作成日時" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 msgid "Expiration Date" msgstr "DL期限" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:256 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:303 msgid "Download Count" msgstr "DL回数" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:275 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:322 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:277 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:324 msgid "Copy" msgstr "コピー" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:283 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:330 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 msgid "message_del_check" msgstr "このURLを削除すると、利用できなくなります。本当に削除しますか?" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:284 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:331 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:286 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:333 msgid "message_del_success" msgstr "URLが削除されました" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:287 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:334 msgid "message_copy_success" msgstr "URLがクリップボードにコピーされました" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:295 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:297 msgid "Onetime URL" msgstr "ワンタイムURL" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:298 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 msgid "User Name" msgstr "ユーザー名" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:338 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:367 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:340 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 msgid "Version" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:5 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:339 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:341 msgid "Stats" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:346 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 msgid "" "Copy Success. Take note of URL. This URL cannot be confirmed again once " "the screen is closed. If you have created a new bucket, please check that" " the bucket is set to public." msgstr "コピーに成功しました。URLを控えてください。この画面を閉じるとURLを再確認することはできません。バケットを新規作成した場合、該当のバケットが公開設定になっているかご確認ください。" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:347 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 msgid "Please select the same named file as the original file." msgstr "元のファイルと同じ名前のファイルを選択してください。" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:350 msgid "File replacement successful." msgstr "ファイルの置き換えに成功しました。" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:351 msgid "Replacing file failed." msgstr "ファイルの置き換えに失敗しました。" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:353 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:355 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Show" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:354 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:356 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Hide" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:368 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 msgid "Date Modified" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 msgid "Object File Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 msgid "File Size" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 msgid "File Hash Value" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:374 msgid "Contributor Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:394 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:396 msgid "Downloads" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:402 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:404 msgid "Plays" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:29 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:412 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:414 msgid "See details" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:453 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 msgid "Chose bucket or input creating bucket name" msgstr "バケット名を選択するか、新規に作成するバケット名を入力してください。" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:457 msgid "Bucket" msgstr "バケット" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:465 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:467 msgid "New Creating Bucket Name" msgstr "新規作成バケット名" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:479 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:481 msgid "Execution" msgstr "実行" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:483 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:485 msgid "Close" msgstr "閉じる" diff --git a/modules/weko-records-ui/weko_records_ui/translations/messages.pot b/modules/weko-records-ui/weko_records_ui/translations/messages.pot index a70b0ed986..107b67c58f 100644 --- a/modules/weko-records-ui/weko_records_ui/translations/messages.pot +++ b/modules/weko-records-ui/weko_records_ui/translations/messages.pot @@ -1,15 +1,15 @@ # Translations template for weko-records-ui. -# Copyright (C) 2025 National Institute of Informatics +# Copyright (C) 2026 National Institute of Informatics # This file is distributed under the same license as the weko-records-ui # project. -# FIRST AUTHOR , 2025. +# FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: weko-records-ui 0.1.0.dev20170000\n" "Report-Msgid-Bugs-To: wekosoftware@nii.ac.jp\n" -"POT-Creation-Date: 2025-12-24 10:03+0900\n" +"POT-Creation-Date: 2026-08-26 17:56+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" -#: tests/test_utils.py:717 weko_records_ui/api.py:678 weko_records_ui/fd.py:650 +#: tests/test_utils.py:717 weko_records_ui/api.py:691 weko_records_ui/fd.py:650 #: weko_records_ui/fd.py:728 weko_records_ui/utils.py:1214 msgid "Unexpected error occurred." msgstr "" @@ -27,7 +27,7 @@ msgstr "" msgid "Failed to send mail." msgstr "" -#: tests/test_views.py:1342 weko_records_ui/views.py:1261 +#: tests/test_views.py:1342 weko_records_ui/views.py:1264 msgid "MSG_WEKO_RECORDS_UI_IS_EDITING_TRUE" msgstr "" @@ -61,49 +61,49 @@ msgstr "" msgid "Bulk Update" msgstr "" -#: weko_records_ui/api.py:220 +#: weko_records_ui/api.py:221 msgid "Not authenticated user." msgstr "" -#: weko_records_ui/api.py:224 weko_records_ui/api.py:227 -#: weko_records_ui/api.py:289 +#: weko_records_ui/api.py:225 weko_records_ui/api.py:228 +#: weko_records_ui/api.py:290 msgid "S3 setting none. Please check your profile." msgstr "" -#: weko_records_ui/api.py:246 +#: weko_records_ui/api.py:247 msgid "Getting Bucket List failed." msgstr "" -#: weko_records_ui/api.py:325 +#: weko_records_ui/api.py:326 msgid "Getting region failed." msgstr "" -#: weko_records_ui/api.py:363 weko_records_ui/api.py:454 +#: weko_records_ui/api.py:374 weko_records_ui/api.py:467 msgid "Uploading file failed." msgstr "" -#: weko_records_ui/api.py:403 weko_records_ui/api.py:660 +#: weko_records_ui/api.py:414 weko_records_ui/api.py:673 msgid "The source bucket or file cannot be found." msgstr "" -#: weko_records_ui/api.py:418 +#: weko_records_ui/api.py:429 msgid "The source file cannot be found." msgstr "" -#: weko_records_ui/api.py:450 +#: weko_records_ui/api.py:463 msgid "The source file size exceeds the limit for cross-service copy." msgstr "" -#: weko_records_ui/api.py:476 +#: weko_records_ui/api.py:489 msgid "Bucket already exists." msgstr "" -#: weko_records_ui/api.py:525 +#: weko_records_ui/api.py:538 msgid "Creating Bucket failed." msgstr "" -#: weko_records_ui/api.py:551 weko_records_ui/api.py:711 -#: weko_records_ui/api.py:712 +#: weko_records_ui/api.py:564 weko_records_ui/api.py:724 +#: weko_records_ui/api.py:725 msgid "Cannot update because the corresponding item is being edited." msgstr "" @@ -296,7 +296,7 @@ msgstr "" msgid "The provided token is invalid." msgstr "" -#: weko_records_ui/utils.py:2338 +#: weko_records_ui/utils.py:2338 weko_records_ui/views.py:1492 msgid "This feature is currently disabled." msgstr "" @@ -308,28 +308,32 @@ msgstr "" msgid "This URL has been deactivated." msgstr "" -#: weko_records_ui/views.py:914 +#: weko_records_ui/views.py:917 msgid "Secret URL generated successfully" msgstr "" -#: weko_records_ui/views.py:923 +#: weko_records_ui/views.py:926 msgid ", please check your email inbox" msgstr "" -#: weko_records_ui/views.py:925 +#: weko_records_ui/views.py:928 msgid "" ", but there was an error while sending the email. To use the URL, please " "refresh the page and copy it from the issued URL list" msgstr "" -#: weko_records_ui/views.py:928 +#: weko_records_ui/views.py:931 msgid "." msgstr "" -#: weko_records_ui/views.py:1158 +#: weko_records_ui/views.py:1161 msgid "PDF cover page settings have been updated." msgstr "" +#: weko_records_ui/views.py:1498 +msgid "You do not have permission to perform this operation." +msgstr "" + #: weko_records_ui/templates/weko_records_ui/_macros.html:47 #: weko_records_ui/templates/weko_records_ui/_macros.html:60 #: weko_records_ui/templates/weko_records_ui/_macros.html:72 @@ -501,8 +505,8 @@ msgid "Edit" msgstr "" #: weko_records_ui/templates/weko_records_ui/body_contents.html:411 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:270 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:317 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:272 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:319 msgid "Delete" msgstr "" @@ -593,198 +597,198 @@ msgid "No title" msgstr "" #: weko_records_ui/templates/weko_records_ui/file_details_contents.html:68 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:257 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:304 msgid "Action" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:132 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 msgid "Replace the file content" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:133 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:134 msgid "Copy file to open bucket" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:157 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:248 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:159 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:250 msgid "Secret URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:170 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:172 msgid "Plagarism Check" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:200 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 msgid "Link Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:202 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:204 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:209 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:215 msgid "Item has not been filled in." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:205 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:207 msgid "URL Expiry Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:208 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:210 msgid "Max Expiry Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:211 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:213 msgid "Download Limit" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:214 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:216 msgid "Max Download Count" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:218 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:220 msgid "Create Secret URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:221 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:223 msgid "Send Email" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:251 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 msgid "Label Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:252 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:299 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 msgid "Create Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:253 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:255 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:302 msgid "Expiration Date" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:254 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:301 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:256 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:303 msgid "Download Count" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:275 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:322 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:277 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:324 msgid "Copy" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:283 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:330 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 msgid "message_del_check" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:284 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:331 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:286 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:333 msgid "message_del_success" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:285 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:332 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:287 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:334 msgid "message_copy_success" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:295 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:297 msgid "Onetime URL" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:298 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:300 msgid "User Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:338 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:367 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:340 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 msgid "Version" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:5 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:339 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:341 msgid "Stats" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:346 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 msgid "" "Copy Success. Take note of URL. This URL cannot be confirmed again once " "the screen is closed. If you have created a new bucket, please check that" " the bucket is set to public." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:347 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 msgid "Please select the same named file as the original file." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:348 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:350 msgid "File replacement successful." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:349 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:351 msgid "Replacing file failed." msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:353 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:355 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Show" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:354 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:356 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:375 msgid "Hide" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:368 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 msgid "Date Modified" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:369 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 msgid "Object File Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:370 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 msgid "File Size" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:371 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:373 msgid "File Hash Value" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:372 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:374 msgid "Contributor Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:394 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:396 msgid "Downloads" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:402 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:404 msgid "Plays" msgstr "" #: weko_records_ui/templates/weko_records_ui/box/stats.html:29 -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:412 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:414 msgid "See details" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:453 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 msgid "Chose bucket or input creating bucket name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:455 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:457 msgid "Bucket" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:465 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:467 msgid "New Creating Bucket Name" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:479 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:481 msgid "Execution" msgstr "" -#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:483 +#: weko_records_ui/templates/weko_records_ui/file_details_contents.html:485 msgid "Close" msgstr "" diff --git a/modules/weko-records-ui/weko_records_ui/views.py b/modules/weko-records-ui/weko_records_ui/views.py index aa7376dc06..afddb1fb10 100644 --- a/modules/weko-records-ui/weko_records_ui/views.py +++ b/modules/weko-records-ui/weko_records_ui/views.py @@ -46,6 +46,7 @@ from invenio_pidrelations.contrib.versioning import PIDVersioning from invenio_pidstore.errors import PIDDoesNotExistError from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from invenio_records_files.models import RecordsBuckets from invenio_records_ui.signals import record_viewed from invenio_files_rest.signals import file_downloaded from invenio_records_ui.utils import obj_or_import_string @@ -1475,8 +1476,87 @@ def dbsession_clean(exception): db.session.remove() +def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, + new_bucket_id=None, new_version_id=None): + """Validate a request for the institutional storage APIs. + + Returns None when the request is valid, otherwise a Flask response tuple + that the caller can return as-is. + """ + user_id = current_user.get_id() + if not current_app.config.get( + 'WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED', False): + current_app.logger.info( + 'Storage modification is disabled. api={}, user_id={}'.format( + request.path, user_id)) + return jsonify({'error': _('This feature is currently disabled.')}), 403 + + if not pid: + return None + + denied = jsonify( + {'error': _('You do not have permission to perform this operation.')}), 403 + try: + record = WekoRecord.get_record_by_pid(pid) + if not check_created_id(record): + current_app.logger.warning( + 'Storage API denied. reason=no_permission, api={}, user_id={}, ' + 'pid={}'.format(request.path, user_id, pid)) + return denied + + pid_obj = PersistentIdentifier.get('recid', pid) + if pid_obj != get_record_without_version(pid_obj): + current_app.logger.warning( + 'Storage API denied. reason=not_base_recid, api={}, user_id={}, ' + 'pid={}'.format(request.path, user_id, pid)) + return denied + + if str(record.get('_buckets', {}).get('deposit')) != str(bucket_id): + current_app.logger.warning( + 'Storage API denied. reason=bucket_mismatch, api={}, user_id={}, ' + 'pid={}, bucket_id={}'.format( + request.path, user_id, pid, bucket_id)) + return denied + + if ObjectVersion.get(bucket=bucket_id, key=file_name) is None: + current_app.logger.warning( + 'Storage API denied. reason=object_not_found, api={}, user_id={}, ' + 'pid={}, bucket_id={}, file_name={}'.format( + request.path, user_id, pid, bucket_id, file_name)) + return denied + + if new_bucket_id: + if ObjectVersion.get(bucket=new_bucket_id, key=file_name, + version_id=new_version_id) is None \ + or RecordsBuckets.query.filter_by( + bucket_id=new_bucket_id).first() is not None: + current_app.logger.warning( + 'Storage API denied. reason=invalid_new_bucket, api={}, ' + 'user_id={}, pid={}, new_bucket_id={}, new_version_id={}'.format( + request.path, user_id, pid, new_bucket_id, new_version_id)) + return denied + except (PIDDoesNotExistError, NoResultFound): + current_app.logger.warning( + 'Storage API denied. reason=pid_not_found, api={}, user_id={}, ' + 'pid={}'.format(request.path, user_id, pid)) + return denied + except Exception as e: + current_app.logger.error( + 'Unexpected error while validating storage API request. ' + 'api={}, user_id={}, pid={}'.format(request.path, user_id, pid)) + current_app.logger.error(traceback.format_exc()) + return jsonify({'error': str(e)}), 400 + + return None + + @blueprint.route("/records/get_bucket_list", methods=['GET']) +@login_required def get_bucket_list(): + error = _validate_storage_api_request() + if error: + return error + try: bucket_list = get_s3_bucket_list() return jsonify(bucket_list) @@ -1485,6 +1565,7 @@ def get_bucket_list(): return jsonify({'error': str(e)}), 400 @blueprint.route("/records/copy_bucket", methods=['POST']) +@login_required def copy_bucket(): data = request.get_json() pid = data.get('pid') @@ -1492,6 +1573,12 @@ def copy_bucket(): bucket_id = data.get('bucket_id') checked = data.get('checked') bucket_name = data.get('bucket_name') + + error = _validate_storage_api_request( + pid=pid, bucket_id=bucket_id, file_name=filename) + if error: + return error + try: uri = copy_bucket_to_s3(pid, filename, bucket_id, checked=checked, bucket_name=bucket_name) return jsonify(uri) @@ -1502,11 +1589,17 @@ def copy_bucket(): @blueprint.route("/records/get_file_place", methods=['POST']) +@login_required def get_file_place(): pid = request.form.get('pid') bucket_id = request.form.get('bucket_id') file_name = request.form.get('file_name') + error = _validate_storage_api_request( + pid=pid, bucket_id=bucket_id, file_name=file_name) + if error: + return error + try: file_place, uri, new_bucket_id, new_version_id = get_file_place_info(pid, bucket_id, file_name) result = { @@ -1521,18 +1614,27 @@ def get_file_place(): return jsonify({'error': str(e)}), 400 @blueprint.route("/records/replace_file", methods=['POST']) +@login_required def replace_file(): return_file_place = request.form.get('return_file_place') + pid = request.form.get('pid') + bucket_id = request.form.get('bucket_id') + file_name = request.form.get('file_name') + new_bucket_id = request.form.get('new_bucket_id') \ + if return_file_place == 'S3' else None + new_version_id = request.form.get('new_version_id') \ + if return_file_place == 'S3' else None + + error = _validate_storage_api_request( + pid=pid, bucket_id=bucket_id, file_name=file_name, + new_bucket_id=new_bucket_id, new_version_id=new_version_id) + if error: + return error if (return_file_place == 'S3'): - pid = request.form.get('pid') - bucket_id = request.form.get('bucket_id') - file_name = request.form.get('file_name') file_size = int(request.form.get('file_size')) file_checksum = request.form.get('file_checksum') - new_bucket_id = request.form.get('new_bucket_id') - new_version_id = request.form.get('new_version_id') try: result = replace_file_bucket(pid, bucket_id, file_name=file_name, file_size=file_size, new_bucket_id=new_bucket_id, @@ -1544,10 +1646,7 @@ def replace_file(): return jsonify({'error': str(e)}), 400 else: - pid = request.form.get('pid') - bucket_id = request.form.get('bucket_id') file = request.files['file'] - file_name = request.form.get('file_name') file_size = int(request.form.get('file_size')) try: From ccc1222f79589deb4f5fdd7bcf46aa3ccaec0699 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Wed, 26 Aug 2026 18:27:49 +0900 Subject: [PATCH 02/13] fix(weko-records-ui)!: restrict public bucket policy to read-only - Change the bucket policy action from s3:* to s3:GetObject - Enable BlockPublicAcls and IgnorePublicAcls Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/weko_records_ui/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/weko-records-ui/weko_records_ui/api.py b/modules/weko-records-ui/weko_records_ui/api.py index 03bbf3f68c..7d6a3d4d1f 100644 --- a/modules/weko-records-ui/weko_records_ui/api.py +++ b/modules/weko-records-ui/weko_records_ui/api.py @@ -509,8 +509,8 @@ def create_storage_bucket(s3_client, endpoint_url, region_name, bucket_name): s3_client.put_public_access_block( Bucket=bucket_name, PublicAccessBlockConfiguration={ - 'BlockPublicAcls': False, - 'IgnorePublicAcls': False, + 'BlockPublicAcls': True, + 'IgnorePublicAcls': True, 'BlockPublicPolicy': False, 'RestrictPublicBuckets': False } @@ -523,7 +523,7 @@ def create_storage_bucket(s3_client, endpoint_url, region_name, bucket_name): "Sid": "Public", "Effect": "Allow", "Principal": "*", - "Action": ["s3:*"], + "Action": ["s3:GetObject"], "Resource": f"arn:aws:s3:::{bucket_name}/*" } ] From c7a1a82916c5800e0804426827a4abd2eac21add Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Wed, 26 Aug 2026 18:45:37 +0900 Subject: [PATCH 03/13] fix(weko-records-ui)!: handle expired storage sessions - Add shared JSON response handling to `bucket.js` for consistent HTTP error processing - Prevent `SyntaxError` alerts and redirect to the login page when the session expires Co-Authored-By: Claude Opus 5 (1M context) --- .../static/js/weko_records_ui/bucket.js | 63 +++++++------------ 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/modules/weko-records-ui/weko_records_ui/static/js/weko_records_ui/bucket.js b/modules/weko-records-ui/weko_records_ui/static/js/weko_records_ui/bucket.js index 20a7546c68..18c7d7c341 100644 --- a/modules/weko-records-ui/weko_records_ui/static/js/weko_records_ui/bucket.js +++ b/modules/weko-records-ui/weko_records_ui/static/js/weko_records_ui/bucket.js @@ -1,3 +1,21 @@ +async function parseJsonResponse(res) { + if (res.redirected) { + // Session expired: fetch followed the redirect to the login page. + window.location.href = res.url; + // Never settles, so the caller's .then()/.catch() will not run. + return new Promise(function () {}); + } + const contentType = res.headers.get('Content-Type') || ''; + if (contentType.indexOf('application/json') === -1) { + throw new Error(res.status + ' ' + res.statusText); + } + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error); + } + return data; +} + async function openBucketCopyModal() { $('#bucket_copy_modal').modal('show'); $('#modal-guide').hide(); @@ -10,14 +28,7 @@ async function openBucketCopyModal() { url ="/records/get_bucket_list"; await fetch(url ,{method:'GET' ,headers:{'Content-Type':'application/json'} ,credentials:"include"}) - .then(res => { - if (!res.ok) { - return res.json().then(errorData => { - throw new Error(errorData.error); - }); - } - return res.json(); - }) + .then(parseJsonResponse) .then((result) => { $('.options-list').empty(); result.forEach(function(bucket_name) { @@ -101,14 +112,7 @@ async function copyFileToBucket() { } url ="/records/copy_bucket"; await fetch(url ,{method:'POST' ,headers:{'Content-Type':'application/json'} ,credentials:"include", body: JSON.stringify(form)}) - .then(res => { - if (!res.ok) { - return res.json().then(errorData => { - throw new Error(errorData.error); - }); - } - return res.json(); - }) + .then(parseJsonResponse) .then(result => { $('#modal-result-message').text(copy_success_message); $('#modal-result-uri').text(result); @@ -156,14 +160,7 @@ document.getElementById('fileInput').addEventListener('change', async function(e url ="/records/get_file_place"; await fetch(url ,{method:'POST', credentials:"include", body: formData}) - .then(res => { - if (!res.ok) { - return res.json().then(errorData => { - throw new Error(errorData.error); - }); - } - return res.json(); - }) + .then(parseJsonResponse) .then(result => { console.log(result); return_file_place = result.file_place @@ -197,14 +194,7 @@ document.getElementById('fileInput').addEventListener('change', async function(e formData_second.append('new_version_id', return_version_id); await fetch(url ,{method:'POST', credentials:"include", body: formData_second}) - .then(res => { - if (!res.ok) { - return res.json().then(errorData => { - throw new Error(errorData.error); - }); - } - return res.json(); - }) + .then(parseJsonResponse) .then(result => { alert(file_replacement_successful_message); window.location = record_url; @@ -224,14 +214,7 @@ document.getElementById('fileInput').addEventListener('change', async function(e formData_second.append('file_size', file.size); await fetch(url ,{method:'POST', credentials:"include", body: formData_second}) - .then(res => { - if (!res.ok) { - return res.json().then(errorData => { - throw new Error(errorData.error); - }); - } - return res.json(); - }) + .then(parseJsonResponse) .then(result => { alert(file_replacement_successful_message); window.location = record_url; From da7aa4bd465b7e52d1aaf58181460b97fd6d743f Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Wed, 26 Aug 2026 18:56:41 +0900 Subject: [PATCH 04/13] fix(invenio-files-rest): resolve storage locations by URI prefix - Replace in-memory location lookup with a SQL-based prefix query Co-Authored-By: Claude Opus 5 (1M context) --- .../invenio_files_rest/storage/pyfs.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py b/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py index db7014e807..7510ff7d83 100644 --- a/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py +++ b/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py @@ -18,6 +18,7 @@ from flask import current_app from fs.opener import opener from fs.path import basename, dirname +from sqlalchemy import String, func, literal from ..helpers import make_path from .base import FileStorage, StorageError @@ -205,7 +206,6 @@ def pyfs_storage_factory(fileinstance=None, default_location=None, from ..models import Location assert fileinstance or (fileurl and size) location = None - locationList = Location.all() if fileinstance: # FIXME: Code here should be refactored since it assumes a lot on the @@ -228,13 +228,20 @@ def pyfs_storage_factory(fileinstance=None, default_location=None, current_app.config['FILES_REST_STORAGE_PATH_SPLIT_LENGTH'], ) - location = next((loc for loc in locationList if str(loc.uri) == str(default_location)), None) + if default_location: + location = Location.query.filter(Location.uri == str(default_location)).first() if location is None: - location = next((loc for loc in locationList if str(loc.uri) in str(fileurl)), None) - if location is None: - # if not match fileurl with location, then get default location - location = next((loc for loc in locationList if loc.default == True), None) + location = Location.query.filter( + func.substr(literal(str(fileurl), String), 1, func.length(Location.uri)) == Location.uri + ).order_by(func.length(Location.uri).desc()).first() + + if location is None: + # if not match fileurl with location, then get default location + location = Location.query.filter_by(default=True).first() + + if location is None: + current_app.logger.warning('No location matched. fileurl={}'.format(fileurl)) return filestorage_class( fileurl, size=size, modified=modified, clean_dir=clean_dir, location=location) From 08e8a799f810cc6e75604c480d23685931355895 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 27 Aug 2026 13:32:17 +0900 Subject: [PATCH 05/13] test(weko-records-ui): add storage API auth and permission tests Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_views.py | 708 ++++++++++++++++---- 1 file changed, 561 insertions(+), 147 deletions(-) diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index 3e0f7dd3ea..1f95a19c63 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -8,6 +8,7 @@ from flask_security.utils import login_user from flask_babelex import gettext as _ from invenio_accounts.testutils import login_user_via_session +from invenio_pidstore.errors import PIDDoesNotExistError from invenio_pidstore.models import PersistentIdentifier, PIDStatus from io import BytesIO from mock import patch @@ -47,11 +48,24 @@ get_workflow_detail, preview_able, get_bucket_list, + _validate_storage_api_request, ) from weko_records_ui.utils import create_download_url from .helpers import login +@pytest.fixture(autouse=True) +def mock_user_activity_log_handler(mocker): + """Mock the user activity audit logger. + + The audit logger writes into the partitioned ``user_activity_logs`` + table, whose partitions are not created in the test database. Mock the + handler so that audit logging never touches the database. + """ + return mocker.patch( + "weko_logging.handler.UserActivityLogHandler.emit", return_value=None) + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp # def record_from_pid(pid_value): @@ -1623,152 +1637,552 @@ def test_publish(app, client, records): publish(record.pid, record_1_b) mock_external.assert_called_with(old_record=record_1_c, new_record=record_0_c) -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_bucket_list(app,records,users): - with app.test_request_context(): - with patch("weko_records_ui.views.get_s3_bucket_list", return_value=[]): - response = get_bucket_list() - assert response.status_code == 200 - with patch("weko_records_ui.views.get_s3_bucket_list",side_effect=Exception): - response = get_bucket_list() - assert response[1] == 400 - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket(app,records,users, client): - - login(client,obj=users[0]["obj"]) - url = url_for("weko_records_ui.copy_bucket") - with patch("weko_records_ui.views.copy_bucket_to_s3", return_value={}): - res = client.post( - url, - data=json.dumps({ - 'pid': '1', - 'file_name': 'helloworld.pdf', - 'bucket_id': '1', - 'checked': 'True', - 'bucket_name': 'name', - }), - content_type='application/json', - ) - assert res.status_code == 200 - with patch("weko_records_ui.views.copy_bucket_to_s3",side_effect=Exception): - res = client.post( - url, - data=json.dumps({ - 'pid': '1', - 'file_name': 'helloworld.pdf', - 'bucket_id': '1', - 'checked': 'True', - 'bucket_name': 'name', - }), - content_type='application/json', - ) - assert res.status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place(app,records,users, client): - login(client,obj=users[0]["obj"]) - url = url_for("weko_records_ui.get_file_place") - with patch( +_COPY_BUCKET_PAYLOAD = { + 'pid': '1', + 'filename': 'helloworld.pdf', + 'bucket_id': '1', + 'checked': 'True', + 'bucket_name': 'name', +} + +_GET_FILE_PLACE_PAYLOAD = { + 'pid': '1', + 'bucket_id': '1', + 'file_name': 'helloworld.pdf', +} + +_REPLACE_FILE_S3_PAYLOAD = { + 'return_file_place': 'S3', + 'pid': '1', + 'bucket_id': '1', + 'file_name': 'helloworld.pdf', + 'file_size': 100, + 'file_checksum': '86266081366d3c950c1cb31fbd9e1c38e4834fa52b568753ce28c87bc31252cd', + 'new_bucket_id': '1', + 'new_version_id': '1', +} + + +def _setup_storage_api(app, client, users, enabled=True, do_login=True): + """Set up the common preconditions of the storage API tests.""" + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = enabled + if do_login: + login(client, obj=users[0]["obj"]) + + +def _call_get_bucket_list(client): + """Call the get_bucket_list API.""" + return client.get(url_for("weko_records_ui.get_bucket_list")) + + +def _call_copy_bucket(client, payload=None): + """Call the copy_bucket API.""" + return client.post( + url_for("weko_records_ui.copy_bucket"), + data=json.dumps(payload if payload is not None else _COPY_BUCKET_PAYLOAD), + content_type='application/json', + ) + + +def _call_get_file_place(client, payload=None): + """Call the get_file_place API.""" + return client.post(url_for("weko_records_ui.get_file_place"), data=dict(payload if payload is not None else _GET_FILE_PLACE_PAYLOAD)) + + +def _call_replace_file_s3(client): + """Call the replace_file API with the S3 branch.""" + return client.post(url_for("weko_records_ui.replace_file"), data=dict(_REPLACE_FILE_S3_PAYLOAD)) + + +def _call_replace_file_local(client): + """Call the replace_file API with the local (else) branch.""" + data = dict(_REPLACE_FILE_S3_PAYLOAD) + data['return_file_place'] = 'local' + data['file'] = FileStorage(stream=BytesIO(b'Hello, World!'), filename='helloworld.pdf', content_type='application/pdf') + return client.post(url_for("weko_records_ui.replace_file"), data=data) + + +def _mock_validation_passed(mocker): + """Mock ``_validate_storage_api_request`` so that validation passes.""" + return mocker.patch("weko_records_ui.views._validate_storage_api_request",return_value=None) + + +def _mock_validation_denied(mocker): + """Mock ``_validate_storage_api_request`` so that it denies the request.""" + return mocker.patch("weko_records_ui.views._validate_storage_api_request", return_value=(jsonify({'error': 'denied'}), 403)) + + +def _mock_storage_backends(mocker): + """Mock every backend the storage APIs delegate to. + + ``get_s3_bucket_list`` / ``copy_bucket_to_s3`` / ``get_file_place_info`` / + ``replace_file_bucket`` all talk to S3 (boto3) and to the database, so they + are mocked unconditionally in every storage API test. The rejection tests + additionally assert that they are never reached, which both keeps the unit + tests hermetic and proves that the guard short-circuits before any storage + access happens. + """ + return { + 'get_s3_bucket_list': mocker.patch("weko_records_ui.views.get_s3_bucket_list"), + 'copy_bucket_to_s3': mocker.patch("weko_records_ui.views.copy_bucket_to_s3"), + 'get_file_place_info': mocker.patch("weko_records_ui.views.get_file_place_info"), + 'replace_file_bucket': mocker.patch("weko_records_ui.views.replace_file_bucket"), + } + + +def _assert_no_storage_access(backends): + """Assert that none of the storage backends have been called.""" + for mock in backends.values(): + mock.assert_not_called() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_bucket_list_success(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.get_s3_bucket_list", return_value=[]) + + res = _call_get_bucket_list(client) + + assert res.status_code == 200 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_bucket_list_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.get_s3_bucket_list", side_effect=Exception) + + res = _call_get_bucket_list(client) + + assert res.status_code == 400 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_success(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.copy_bucket_to_s3", return_value={}) + + res = _call_copy_bucket(client) + + assert res.status_code == 200 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.copy_bucket_to_s3", side_effect=Exception) + + res = _call_copy_bucket(client) + + assert res.status_code == 400 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_success(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch( "weko_records_ui.views.get_file_place_info", - return_value={ - "file_place": 'file_place', - "uri": 'uri', - "new_bucket_id": 'new_bucket_id', - "new_version_id": 'new_version_id' - } - ): - res = client.post( - url, - data={ - 'pid': '1', - 'bucket_id': '1', - 'file_name': 'helloworld.pdf', - }, - ) - assert res.status_code == 200 - with patch("weko_records_ui.views.get_file_place_info",side_effect=Exception): - res = client.post( - url, - data={ - 'pid': '1', - 'bucket_id': '1', - 'file_name': 'helloworld.pdf', - }, - ) - assert res.status_code == 400 - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file(app,records,users, client): - login(client,obj=users[0]["obj"]) - url = url_for("weko_records_ui.replace_file") - # テスト用のデータを用意 - test_data = b'Hello, World!' # バイナリデータ - # BytesIOオブジェクトを作成 - # FileStorageオブジェクトを作成 - with patch("weko_records_ui.views.replace_file_bucket", return_value={}): - res = client.post( - url, - data={ - 'return_file_place': 'S3', - 'pid': '1', - 'bucket_id':'1', - 'file_name': 'helloworld.pdf', - 'file_size': 100, - 'file_checksum': '86266081366d3c950c1cb31fbd9e1c38e4834fa52b568753ce28c87bc31252cd', - 'new_bucket_id': '1', - 'new_version_id': '1', - }, - ) - assert res.status_code == 200 - with patch("weko_records_ui.views.replace_file_bucket",side_effect=Exception): - res = client.post( - url, - data={ - 'return_file_place': 'S3', - 'pid': '1', - 'bucket_id':'1', - 'file_name': 'helloworld.pdf', - 'file_size': 100, - 'file_checksum': '86266081366d3c950c1cb31fbd9e1c38e4834fa52b568753ce28c87bc31252cd', - 'new_bucket_id': '1', - 'new_version_id': '1', - }, - ) - assert res.status_code == 400 - with patch("weko_records_ui.views.replace_file_bucket", return_value={}): - virtual_file = BytesIO(test_data) - file = FileStorage(stream=virtual_file, filename='helloworld.pdf', content_type='application/pdf') - res = client.post( - url, - data={ - 'return_file_place': 'local', - 'pid': '1', - 'bucket_id':'1', - 'file_name': 'helloworld.pdf', - 'file_size': 100, - 'file_checksum': '86266081366d3c950c1cb31fbd9e1c38e4834fa52b568753ce28c87bc31252cd', - 'new_bucket_id': '1', - 'new_version_id': '1', - 'file': file, - }, - ) - assert res.status_code == 200 - with patch("weko_records_ui.views.replace_file_bucket",side_effect=Exception): - virtual_file = BytesIO(test_data) - file = FileStorage(stream=virtual_file, filename='helloworld.pdf', content_type='application/pdf') - res = client.post( - url, - data={ - 'return_file_place': 'local', - 'pid': '1', - 'bucket_id':'1', - 'file_name': 'helloworld.pdf', - 'file_size': 100, - 'file_checksum': '86266081366d3c950c1cb31fbd9e1c38e4834fa52b568753ce28c87bc31252cd', - 'new_bucket_id': '1', - 'new_version_id': '1', - 'file': file, - }, - ) - assert res.status_code == 400 + return_value=('file_place', 'uri', 'new_bucket_id', 'new_version_id')) + + res = _call_get_file_place(client) + + assert res.status_code == 200 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.get_file_place_info", + side_effect=Exception) + + res = _call_get_file_place(client) + + assert res.status_code == 400 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_s3_success(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) + + res = _call_replace_file_s3(client) + + assert res.status_code == 200 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_s3_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", + side_effect=Exception) + + res = _call_replace_file_s3(client) + + assert res.status_code == 400 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_local_success(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) + + res = _call_replace_file_local(client) + + assert res.status_code == 200 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_local_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", + side_effect=Exception) + + res = _call_replace_file_local(client) + + assert res.status_code == 400 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_bucket_list_requires_login(app, users, client, mocker): + _setup_storage_api(app, client, users, do_login=False) + backends = _mock_storage_backends(mocker) + + res = _call_get_bucket_list(client) + + assert res.status_code == 302 + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_requires_login(app, users, client, mocker): + _setup_storage_api(app, client, users, do_login=False) + backends = _mock_storage_backends(mocker) + + res = _call_copy_bucket(client) + + assert res.status_code == 302 + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_requires_login(app, users, client, mocker): + _setup_storage_api(app, client, users, do_login=False) + backends = _mock_storage_backends(mocker) + + res = _call_get_file_place(client) + + assert res.status_code == 302 + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_requires_login(app, users, client, mocker): + _setup_storage_api(app, client, users, do_login=False) + backends = _mock_storage_backends(mocker) + + res = _call_replace_file_s3(client) + + assert res.status_code == 302 + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_bucket_list_denied_when_disabled(app, users, client, mocker): + _setup_storage_api(app, client, users, enabled=False) + backends = _mock_storage_backends(mocker) + + res = _call_get_bucket_list(client) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_denied_when_disabled(app, users, client, mocker): + _setup_storage_api(app, client, users, enabled=False) + backends = _mock_storage_backends(mocker) + + res = _call_copy_bucket(client) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_denied_when_disabled(app, users, client, mocker): + _setup_storage_api(app, client, users, enabled=False) + backends = _mock_storage_backends(mocker) + + res = _call_get_file_place(client) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_denied_when_disabled(app, users, client, mocker): + _setup_storage_api(app, client, users, enabled=False) + backends = _mock_storage_backends(mocker) + + res = _call_replace_file_s3(client) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_returns_validation_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_denied(mocker) + backends = _mock_storage_backends(mocker) + + res = _call_copy_bucket(client) + + assert res.status_code == 403 + backends['copy_bucket_to_s3'].assert_not_called() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_returns_validation_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_denied(mocker) + backends = _mock_storage_backends(mocker) + + res = _call_get_file_place(client) + + assert res.status_code == 403 + backends['get_file_place_info'].assert_not_called() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_returns_validation_error(app, users, client, mocker): + _setup_storage_api(app, client, users) + _mock_validation_denied(mocker) + backends = _mock_storage_backends(mocker) + + res = _call_replace_file_s3(client) + + assert res.status_code == 403 + backends['replace_file_bucket'].assert_not_called() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_passes_validation_params -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_passes_validation_params(app, users, client, mocker): + """The JSON body must reach the validator under the right keyword names. + + ``copy_bucket`` reads the file name from the JSON key ``filename`` but + passes it to the validator as ``file_name``. Distinct values are used for + every field so that a swapped or renamed key is detected. + """ + _setup_storage_api(app, client, users) + mock_validate = _mock_validation_passed(mocker) + backends = _mock_storage_backends(mocker) + backends['copy_bucket_to_s3'].return_value = {} + payload = dict(_COPY_BUCKET_PAYLOAD, pid='11', bucket_id='22', filename='target.pdf') + + res = _call_copy_bucket(client, payload) + + assert res.status_code == 200 + mock_validate.assert_called_once_with( + pid='11', bucket_id='22', file_name='target.pdf') + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_passes_validation_params -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_passes_validation_params(app, users, client, mocker): + """The form fields must reach the validator under the right keyword names. + + Distinct values are used for every field so that a swapped or renamed + form key is detected. + """ + _setup_storage_api(app, client, users) + mock_validate = _mock_validation_passed(mocker) + backends = _mock_storage_backends(mocker) + backends['get_file_place_info'].return_value = ( + 'file_place', 'uri', 'new_bucket_id', 'new_version_id') + payload = dict(_GET_FILE_PLACE_PAYLOAD, pid='11', bucket_id='22', file_name='target.pdf') + + res = _call_get_file_place(client, payload) + + assert res.status_code == 200 + mock_validate.assert_called_once_with( + pid='11', bucket_id='22', file_name='target.pdf') + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_passes_new_bucket_params_s3 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_passes_new_bucket_params_s3(app, users, client, mocker): + _setup_storage_api(app, client, users) + mock_validate = _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) + + res = _call_replace_file_s3(client) + + assert res.status_code == 200 + mock_validate.assert_called_once_with(pid='1', bucket_id='1', file_name='helloworld.pdf', new_bucket_id='1', new_version_id='1') + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_passes_new_bucket_params_local -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_passes_new_bucket_params_local(app, users, client, + mocker): + _setup_storage_api(app, client, users) + mock_validate = _mock_validation_passed(mocker) + mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) + + res = _call_replace_file_local(client) + + assert res.status_code == 200 + mock_validate.assert_called_once_with(pid='1', bucket_id='1', file_name='helloworld.pdf', new_bucket_id=None, new_version_id=None) + + + +def _mock_validation_dependencies(mocker, deposit_bucket='aaa'): + """Mock the dependencies of ``_validate_storage_api_request``. + + The mocks let the ownership check and the base recid check pass, so that + each test only has to override the branch it wants to exercise. + """ + pid_obj = mocker.MagicMock() + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': deposit_bucket}}) + mocker.patch("weko_records_ui.views.check_created_id", return_value=True) + mocker.patch("weko_records_ui.views.PersistentIdentifier.get", return_value=pid_obj) + mocker.patch("weko_records_ui.views.get_record_without_version", return_value=pid_obj) + return pid_obj + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_disabled(app): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = False + with app.test_request_context(): + result = _validate_storage_api_request( + pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_no_pid(app): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + with app.test_request_context(): + result = _validate_storage_api_request() + assert result is None + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_permission -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_no_permission(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) + mocker.patch("weko_records_ui.views.check_created_id", return_value=False) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_not_base_recid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_not_base_recid(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) + mocker.patch("weko_records_ui.views.check_created_id", return_value=True) + mocker.patch("weko_records_ui.views.PersistentIdentifier.get", return_value=mocker.MagicMock()) + mocker.patch("weko_records_ui.views.get_record_without_version", return_value=mocker.MagicMock()) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_bucket_mismatch -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_bucket_mismatch(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='bbb', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_object_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_object_not_found(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=None) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_invalid_new_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_invalid_new_version(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mocker.patch("weko_records_ui.views.ObjectVersion.get", + side_effect=[mocker.MagicMock(), None]) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='1') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_attached -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_new_bucket_attached(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mocker.patch("weko_records_ui.views.ObjectVersion.get", + return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = \ + mocker.MagicMock() + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='1') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_pid_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_pid_not_found(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=PIDDoesNotExistError('recid', '999')) + with app.test_request_context(): + result = _validate_storage_api_request(pid='999', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert result[1] != 404 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_unexpected_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_unexpected_error(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=Exception('boom')) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 400 + assert result[0].get_json()['error'] == 'boom' + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_success(app, mocker): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf',new_bucket_id='bbb', new_version_id='1') + assert result is None From 5b3fa116456c0b8767b9eb319dfd69318ab76023 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 27 Aug 2026 14:54:18 +0900 Subject: [PATCH 06/13] test(weko-records-ui): update bucket policy tests for read-only access Align the create_storage_bucket tests with the read-only policy applied in ccc1222f7: BlockPublicAcls and IgnorePublicAcls are now True, and the bucket policy action is s3:GetObject. Strengthen the non-default-region and non-AWS-endpoint cases, which only asserted that the calls happened, so they now verify the public access block configuration and the policy action as well. Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_api.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/weko-records-ui/tests/test_api.py b/modules/weko-records-ui/tests/test_api.py index 092a2992f0..dd2335fed0 100644 --- a/modules/weko-records-ui/tests/test_api.py +++ b/modules/weko-records-ui/tests/test_api.py @@ -925,8 +925,8 @@ def test_create_storage_bucket_success_default_region(mocker): mock_s3_client.put_public_access_block.assert_called_once_with( Bucket="test-bucket", PublicAccessBlockConfiguration={ - 'BlockPublicAcls': False, - 'IgnorePublicAcls': False, + 'BlockPublicAcls': True, + 'IgnorePublicAcls': True, 'BlockPublicPolicy': False, 'RestrictPublicBuckets': False }) @@ -939,7 +939,7 @@ def test_create_storage_bucket_success_default_region(mocker): "Sid": "Public", "Effect": "Allow", "Principal": "*", - "Action": ["s3:*"], + "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::test-bucket/*" } ] @@ -961,8 +961,18 @@ def test_create_storage_bucket_success_non_default_region(mocker): Bucket="test-bucket", CreateBucketConfiguration={'LocationConstraint': "ap-northeast-1"} ) - mock_s3_client.put_public_access_block.assert_called_once() + mock_s3_client.put_public_access_block.assert_called_once_with( + Bucket="test-bucket", + PublicAccessBlockConfiguration={ + 'BlockPublicAcls': True, + 'IgnorePublicAcls': True, + 'BlockPublicPolicy': False, + 'RestrictPublicBuckets': False + }) mock_s3_client.put_bucket_policy.assert_called_once() + policy = json.loads( + mock_s3_client.put_bucket_policy.call_args[1]["Policy"]) + assert policy["Statement"][0]["Action"] == ["s3:GetObject"] # def create_storage_bucket(s3_client, endpoint_url, region_name, bucket_name): @@ -979,6 +989,9 @@ def test_create_storage_bucket_success_non_aws_endpoint(mocker): mock_s3_client.create_bucket.assert_called_once_with(Bucket="test-bucket") mock_s3_client.put_public_access_block.assert_not_called() mock_s3_client.put_bucket_policy.assert_called_once() + policy = json.loads( + mock_s3_client.put_bucket_policy.call_args[1]["Policy"]) + assert policy["Statement"][0]["Action"] == ["s3:GetObject"] # def create_storage_bucket(s3_client, endpoint_url, region_name, bucket_name): From fbb1cc9771a00671b940ff8a41dd7586358f2c85 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 27 Aug 2026 20:53:08 +0900 Subject: [PATCH 07/13] test(invenio-files-rest): add pyfs storage factory location tests Co-Authored-By: Claude Opus 5 (1M context) --- .../invenio-files-rest/tests/test_storage.py | 187 +++++++++++++++++- 1 file changed, 185 insertions(+), 2 deletions(-) diff --git a/modules/invenio-files-rest/tests/test_storage.py b/modules/invenio-files-rest/tests/test_storage.py index 4bb51439e4..4daba5d39e 100644 --- a/modules/invenio-files-rest/tests/test_storage.py +++ b/modules/invenio-files-rest/tests/test_storage.py @@ -17,13 +17,16 @@ import pytest from fs.errors import DirectoryNotEmptyError, ResourceNotFoundError -from mock import patch +from unittest.mock import patch from six import BytesIO +from sqlalchemy import event from invenio_files_rest.errors import FileSizeError, StorageError, \ UnexpectedFileSizeError from invenio_files_rest.limiters import FileSizeLimit -from invenio_files_rest.storage import FileStorage, PyFSFileStorage +from invenio_files_rest.models import Location +from invenio_files_rest.storage import FileStorage, PyFSFileStorage, \ + pyfs_storage_factory def test_storage_interface(): @@ -348,3 +351,183 @@ def test_non_unicode_filename(app, pyfs): 'żółć.txt', mimetype='text/plain', checksum=checksum) assert res.status_code == 200 assert res.headers['Content-Disposition'] == 'inline' + + +def _add_location(db, name, uri, default=False): + """Add a location row and commit it. + + ``Location.name`` is validated against ``^[a-z][a-z0-9-]+$`` + (``invenio_files_rest/models.py``), so names must be two characters or + longer, start with a lower-case letter and contain only lower-case + alphanumerics and dashes. + """ + loc = Location(name=name, uri=uri, default=default) + db.session.add(loc) + db.session.commit() + return loc + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_prefix_match -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_prefix_match(app, db, dummy_location): + """Test that a location whose URI prefixes the fileurl is selected.""" + _add_location(db, 'loc-a', 's3://bucket-a') + + storage = pyfs_storage_factory(fileurl='s3://bucket-a/ab/cd/ef/data', size=1) + + assert storage.location is not None + assert storage.location.name == 'loc-a' + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_longest_prefix_wins -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_longest_prefix_wins(app, db, dummy_location): + """Test that the longest matching location URI wins. + + The shorter URI is inserted first on purpose: without the + ``ORDER BY length(uri) DESC`` clause PostgreSQL returns rows in physical + (insert) order, so dropping the ordering makes this test fail. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + _add_location(db, 'loc-b', 's3://bucket-a/sub') + + storage = pyfs_storage_factory(fileurl='s3://bucket-a/sub/ab/cd/data', size=1) + + assert storage.location is not None + assert storage.location.name == 'loc-b' + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_no_partial_match -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_no_partial_match(app, db, dummy_location): + """Test that a location URI matches only at the start of the fileurl. + + ``/mnt/other`` appears in the fileurl but not as a prefix, so it must not + be selected and the default location must be used instead. + """ + _add_location(db, 'loc-x', '/mnt/other') + + storage = pyfs_storage_factory(fileurl='/mnt/data/backup/mnt/other/ab/data', size=1) + + assert storage.location is not None + assert storage.location.name != 'loc-x' + assert storage.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_uri_underscore_not_wildcard -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_uri_underscore_not_wildcard( + app, db, dummy_location): + """Test that an underscore in a location URI is not a LIKE wildcard.""" + _add_location(db, 'loc-us', 's3://weko_bucket') + + storage = pyfs_storage_factory(fileurl='s3://wekoxbucket/ab/data', size=1) + + assert storage.location is not None + assert storage.location.name != 'loc-us' + assert storage.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_default_fallback -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_default_fallback(app, db, dummy_location): + """Test the fallback to the default location when nothing matches.""" + storage = pyfs_storage_factory(fileurl='s3://nowhere/ab/data', size=1) + + assert storage.location is not None + assert storage.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_no_location_logs_warning -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_no_location_logs_warning(app, db, mocker): + """Test that a warning is logged when no location can be resolved. + + No location fixture is requested on purpose: with a default location + present the fallback would succeed and no warning would be emitted. + """ + warning_mock = mocker.patch.object(app.logger, 'warning') + + storage = pyfs_storage_factory(fileurl='s3://nowhere/ab/data', size=1) + + assert storage.location is None + warning_mock.assert_called_once() + assert 's3://nowhere/ab/data' in warning_mock.call_args[0][0] + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_default_location_match -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_default_location_match( + app, db, dummy_location, mocker): + """Test that an explicit default_location takes precedence. + + ``loc-a`` prefixes the fileurl and would win the prefix lookup, so it also + proves that the prefix lookup is not executed once the URI of + ``default_location`` has been resolved. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + + fileinstance = mocker.MagicMock() + fileinstance.size = 1 + fileinstance.updated = None + fileinstance.uri = 's3://bucket-a/ab/data' + + storage = pyfs_storage_factory( + fileinstance=fileinstance, default_location=dummy_location.uri) + + assert storage.location is not None + assert storage.location.name != 'loc-a' + assert storage.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_skips_query_when_no_default_location -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_skips_query_when_no_default_location( + app, db, mocker): + """Test that no query is issued when default_location is not given. + + ``loc-none`` has the literal URI ``'None'``: without the guard the lookup + would compare against ``str(None)`` and select it. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + _add_location(db, 'loc-none', 'None') + + fileinstance = mocker.MagicMock() + fileinstance.size = 1 + fileinstance.updated = None + fileinstance.uri = 's3://bucket-a/ab/data' + + statements = [] + + def _record(conn, cursor, statement, parameters, context, executemany): + statements.append(statement) + + event.listen(db.engine, 'before_cursor_execute', _record) + try: + storage = pyfs_storage_factory(fileinstance=fileinstance) + finally: + event.remove(db.engine, 'before_cursor_execute', _record) + + assert storage.location is not None + assert storage.location.name != 'loc-none' + assert storage.location.name == 'loc-a' + assert len(statements) == 1 + assert 'substr' in statements[0].lower() + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_no_full_scan -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_no_full_scan(app, db, dummy_location, mocker): + """Test that the whole location table is never loaded into memory.""" + _add_location(db, 'loc-a', 's3://bucket-a') + mock_all = mocker.patch('invenio_files_rest.models.Location.all') + + storage = pyfs_storage_factory(fileurl='s3://bucket-a/ab/data', size=1) + + mock_all.assert_not_called() + assert storage.location is not None + assert storage.location.name == 'loc-a' + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_passes_args_to_filestorage_class -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_passes_args_to_filestorage_class(app, db, dummy_location, mocker): + """Test the arguments handed over to the file storage class.""" + loc_a = _add_location(db, 'loc-a', 's3://bucket-a') + fake_class = mocker.MagicMock() + + storage = pyfs_storage_factory(fileurl='s3://bucket-a/ab/data', size=1, filestorage_class=fake_class) + + fake_class.assert_called_once_with('s3://bucket-a/ab/data', size=1, modified=None, clean_dir=True, location=loc_a) + assert fake_class.call_args[1]['location'].name == 'loc-a' + assert storage is fake_class.return_value From db3c2ebdceac23c091a5ef16548dfbb9efa36c3a Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 27 Aug 2026 21:00:11 +0900 Subject: [PATCH 08/13] fix(tests): drop existing database before test --- modules/weko-records-ui/tests/conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/weko-records-ui/tests/conftest.py b/modules/weko-records-ui/tests/conftest.py index 4e0b498053..7c12adf50b 100644 --- a/modules/weko-records-ui/tests/conftest.py +++ b/modules/weko-records-ui/tests/conftest.py @@ -79,7 +79,7 @@ from invenio_search_ui import InvenioSearchUI from invenio_theme import InvenioTheme from six import BytesIO -from sqlalchemy_utils.functions import create_database, database_exists +from sqlalchemy_utils.functions import create_database, database_exists, drop_database from weko_admin import WekoAdmin from weko_admin.models import SessionLifetime from weko_admin.models import AdminSettings @@ -380,8 +380,9 @@ def esindex(app): @pytest.yield_fixture() def db(app): """Database fixture.""" - if not database_exists(str(db_.engine.url)): - create_database(str(db_.engine.url)) + if database_exists(str(db_.engine.url)): + drop_database(str(db_.engine.url)) + create_database(str(db_.engine.url)) db_.create_all() yield db_ db_.session.remove() From 344f6729b58a0247ee35e5a3ecc3b41efcf921d0 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Fri, 28 Aug 2026 11:46:37 +0900 Subject: [PATCH 09/13] fix(invenio-files-rest): require path boundary in location match - Require a separator right after Location.uri, so s3://bucket-a no longer matches s3://bucket-a2 and supplies the wrong S3 credentials - Keep the substr equality comparison (no LIKE) and the longest-match ordering unchanged - Add regression tests for similar bucket names, trailing-slash URIs, local path boundaries and exact URI matches Co-Authored-By: Claude Opus 5 (1M context) --- .../invenio_files_rest/storage/pyfs.py | 23 ++++- .../invenio-files-rest/tests/test_storage.py | 90 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py b/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py index 7510ff7d83..ca1ccef55b 100644 --- a/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py +++ b/modules/invenio-files-rest/invenio_files_rest/storage/pyfs.py @@ -18,7 +18,7 @@ from flask import current_app from fs.opener import opener from fs.path import basename, dirname -from sqlalchemy import String, func, literal +from sqlalchemy import String, and_, func, literal, or_ from ..helpers import make_path from .base import FileStorage, StorageError @@ -232,9 +232,26 @@ def pyfs_storage_factory(fileinstance=None, default_location=None, location = Location.query.filter(Location.uri == str(default_location)).first() if location is None: + # Match ``Location.uri`` as a path prefix of ``fileurl``, not as a + # plain text prefix: a boundary is required right after the URI so + # that e.g. the location ``s3://bucket-a`` never matches a file + # stored in ``s3://bucket-a2``. Selecting the wrong location would + # hand out the wrong (S3) credentials for the file. + fileurl_expr = literal(str(fileurl), String) + uri_length = func.length(Location.uri) location = Location.query.filter( - func.substr(literal(str(fileurl), String), 1, func.length(Location.uri)) == Location.uri - ).order_by(func.length(Location.uri).desc()).first() + and_( + func.substr(fileurl_expr, 1, uri_length) == Location.uri, + or_( + # fileurl is exactly the location URI + func.length(fileurl_expr) == uri_length, + # the location URI already ends with a separator + func.substr(Location.uri, uri_length, 1) == '/', + # the character right after the URI is a separator + func.substr(fileurl_expr, uri_length + 1, 1) == '/', + ), + ) + ).order_by(uri_length.desc()).first() if location is None: # if not match fileurl with location, then get default location diff --git a/modules/invenio-files-rest/tests/test_storage.py b/modules/invenio-files-rest/tests/test_storage.py index 4daba5d39e..de97bb8c79 100644 --- a/modules/invenio-files-rest/tests/test_storage.py +++ b/modules/invenio-files-rest/tests/test_storage.py @@ -531,3 +531,93 @@ def test_pyfs_storage_factory_passes_args_to_filestorage_class(app, db, dummy_lo fake_class.assert_called_once_with('s3://bucket-a/ab/data', size=1, modified=None, clean_dir=True, location=loc_a) assert fake_class.call_args[1]['location'].name == 'loc-a' assert storage is fake_class.return_value + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_similar_bucket_name_not_matched -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_similar_bucket_name_not_matched( + app, db, dummy_location): + """Test that a location URI only matches on a path boundary. + + ``s3://bucket-a`` is a plain text prefix of ``s3://bucket-a2/...`` but not + a path prefix of it. Without the boundary condition ``loc-a`` would be + selected and would supply the S3 credentials of the wrong account for a + file that actually lives in another bucket. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + + storage = pyfs_storage_factory(fileurl='s3://bucket-a2/ab/data', size=1) + + assert storage.location is not None + assert storage.location.name != 'loc-a' + assert storage.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_uri_with_trailing_slash -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_uri_with_trailing_slash(app, db, dummy_location): + """Test that a location URI already ending with ``/`` still matches. + + The boundary must not be required twice: for ``s3://bucket-b/`` the + separator is part of the URI itself, so the character following it is a + regular path character and the location must still be selected. + """ + _add_location(db, 'loc-b', 's3://bucket-b/') + + storage = pyfs_storage_factory(fileurl='s3://bucket-b/ab/data', size=1) + + assert storage.location is not None + assert storage.location.name == 'loc-b' + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_similar_bucket_names_coexist -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_similar_bucket_names_coexist( + app, db, dummy_location): + """Test that similarly named buckets each resolve to their own location. + + Both ``s3://bucket-a`` and ``s3://bucket-a2`` are registered, so a purely + textual prefix match would resolve both file URLs to ``loc-a`` and mix up + the credentials of the two buckets. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + _add_location(db, 'loc-a2', 's3://bucket-a2') + + storage_a = pyfs_storage_factory(fileurl='s3://bucket-a/ab/data', size=1) + storage_a2 = pyfs_storage_factory(fileurl='s3://bucket-a2/ab/data', size=1) + + assert storage_a.location is not None + assert storage_a.location.name == 'loc-a' + assert storage_a2.location is not None + assert storage_a2.location.name == 'loc-a2' + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_local_path_boundary -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_local_path_boundary(app, db, dummy_location): + """Test that the boundary also applies to local file system locations. + + ``/mnt/data`` must not swallow files stored below ``/mnt/data2``, which + may be a completely different mount point. + """ + _add_location(db, 'loc-data', '/mnt/data') + _add_location(db, 'loc-data2', '/mnt/data2') + + storage = pyfs_storage_factory(fileurl='/mnt/data2/ab/data', size=1) + storage_other = pyfs_storage_factory(fileurl='/mnt/database/ab/data', size=1) + + assert storage.location is not None + assert storage.location.name == 'loc-data2' + assert storage_other.location is not None + assert storage_other.location.id == dummy_location.id + + +# .tox/c1/bin/pytest --cov=invenio_files_rest tests/test_storage.py::test_pyfs_storage_factory_exact_uri_match -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/invenio-files-rest/.tox/c1/tmp +def test_pyfs_storage_factory_exact_uri_match(app, db, dummy_location): + """Test that a fileurl equal to the location URI still matches. + + There is no character left after the URI to carry the separator, so the + boundary check has to accept an exact match as well. + """ + _add_location(db, 'loc-a', 's3://bucket-a') + + storage = pyfs_storage_factory(fileurl='s3://bucket-a', size=1) + + assert storage.location is not None + assert storage.location.name == 'loc-a' From 3031d62e0ee06f1aca67acbec8c684bdf44a97af Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Fri, 28 Aug 2026 16:50:04 +0900 Subject: [PATCH 10/13] fix(weko-records-ui): require pid on record-scoped storage APIs - Require pid on the record-scoped APIs and return 403 when it is missing; only get_bucket_list opts out via feature_flag_only - Require new_bucket_id and new_version_id together, since ObjectVersion.get falls back to the head version without version_id Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_views.py | 218 +++++++++++++++++- .../weko-records-ui/weko_records_ui/views.py | 27 ++- 2 files changed, 235 insertions(+), 10 deletions(-) diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index 1f95a19c63..cdd7412299 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -1690,9 +1690,9 @@ def _call_get_file_place(client, payload=None): return client.post(url_for("weko_records_ui.get_file_place"), data=dict(payload if payload is not None else _GET_FILE_PLACE_PAYLOAD)) -def _call_replace_file_s3(client): +def _call_replace_file_s3(client, payload=None): """Call the replace_file API with the S3 branch.""" - return client.post(url_for("weko_records_ui.replace_file"), data=dict(_REPLACE_FILE_S3_PAYLOAD)) + return client.post(url_for("weko_records_ui.replace_file"), data=dict(payload if payload is not None else _REPLACE_FILE_S3_PAYLOAD)) def _call_replace_file_local(client): @@ -2051,6 +2051,117 @@ def test_replace_file_passes_new_bucket_params_local(app, users, client, +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_copy_bucket_denied_without_pid(app, users, client, mocker): + """``pid`` is attacker controlled, so omitting it must not bypass the checks. + + ``copy_bucket`` reads ``pid`` from the JSON body, and + ``copy_bucket_to_s3`` locates the file from ``bucket_id`` / ``filename`` + alone. Without this guard any logged in user could copy somebody else's + file into their own S3 bucket simply by leaving ``pid`` out. + """ + _setup_storage_api(app, client, users) + backends = _mock_storage_backends(mocker) + payload = dict(_COPY_BUCKET_PAYLOAD) + del payload['pid'] + + res = _call_copy_bucket(client, payload) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_file_place_denied_without_pid(app, users, client, mocker): + """A request without ``pid`` must be rejected instead of being trusted.""" + _setup_storage_api(app, client, users) + backends = _mock_storage_backends(mocker) + payload = dict(_GET_FILE_PLACE_PAYLOAD) + del payload['pid'] + + res = _call_get_file_place(client, payload) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_denied_without_pid(app, users, client, mocker): + """A request without ``pid`` must be rejected instead of being trusted.""" + _setup_storage_api(app, client, users) + backends = _mock_storage_backends(mocker) + payload = dict(_REPLACE_FILE_S3_PAYLOAD) + del payload['pid'] + + res = _call_replace_file_s3(client, payload) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_allowed_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_get_bucket_list_allowed_without_pid(app, users, client, mocker): + """``get_bucket_list`` keeps working without ``pid``. + + It does not operate on a single record, so it opts out of the record based + checks explicitly. The real validator is used here (it is not mocked) so + that making ``pid`` mandatory cannot silently break this API. + """ + _setup_storage_api(app, client, users) + mocker.patch("weko_records_ui.views.get_s3_bucket_list", return_value=[]) + + res = _call_get_bucket_list(client) + + assert res.status_code == 200 + assert res.get_json() == [] + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_new_version_id -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_denied_without_new_version_id(app, users, client, mocker): + """``new_bucket_id`` without ``new_version_id`` must be rejected at the entrance. + + Otherwise ``ObjectVersion.get()`` silently falls back to the head version, + the request passes validation and ``None`` ends up stored as the file's + ``version_id`` in the record metadata. + """ + _setup_storage_api(app, client, users) + _mock_validation_dependencies(mocker, deposit_bucket='1') + mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + backends = _mock_storage_backends(mocker) + payload = dict(_REPLACE_FILE_S3_PAYLOAD) + del payload['new_version_id'] + + res = _call_replace_file_s3(client, payload) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_new_bucket_id -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test_replace_file_denied_without_new_bucket_id(app, users, client, mocker): + """``new_version_id`` without ``new_bucket_id`` must be rejected as well.""" + _setup_storage_api(app, client, users) + _mock_validation_dependencies(mocker, deposit_bucket='1') + mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + backends = _mock_storage_backends(mocker) + payload = dict(_REPLACE_FILE_S3_PAYLOAD) + del payload['new_bucket_id'] + + res = _call_replace_file_s3(client, payload) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + def _mock_validation_dependencies(mocker, deposit_bucket='aaa'): """Mock the dependencies of ``_validate_storage_api_request``. @@ -2075,12 +2186,62 @@ def test__validate_storage_api_request_disabled(app): assert 'error' in result[0].get_json() +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_feature_flag_only -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_feature_flag_only(app): + """``feature_flag_only=True`` stops right after the feature flag check. + + This is the only way to skip the record based checks, and it is used by + ``get_bucket_list``, which does not operate on a single record. + """ + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + with app.test_request_context(): + result = _validate_storage_api_request(feature_flag_only=True) + assert result is None + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_no_pid(app): +def test__validate_storage_api_request_no_pid(app, mocker): + """Omitting ``pid`` must not skip the record based checks. + + ``pid`` comes from the request body, so a caller could otherwise disable + the ownership, base recid and bucket checks simply by leaving it out. + """ app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mock_get_record = mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid") with app.test_request_context(): result = _validate_storage_api_request() - assert result is None + assert result[1] == 403 + assert 'error' in result[0].get_json() + mock_get_record.assert_not_called() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_empty_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_empty_pid(app, mocker): + """An empty ``pid`` string is rejected just like a missing one.""" + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mock_get_record = mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid") + with app.test_request_context(): + result = _validate_storage_api_request(pid='', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 403 + assert 'error' in result[0].get_json() + mock_get_record.assert_not_called() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_denied_message_is_shared -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_denied_message_is_shared(app, mocker): + """Every rejection reason must be indistinguishable in the response. + + The missing pid rejection reuses the existing permission message so that + the response never reveals which check failed. + """ + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) + mocker.patch("weko_records_ui.views.check_created_id", return_value=False) + with app.test_request_context(): + no_permission = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + no_pid = _validate_storage_api_request() + assert no_pid[1] == no_permission[1] == 403 + assert no_pid[0].get_json() == no_permission[0].get_json() # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_permission -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp @@ -2155,6 +2316,55 @@ def test__validate_storage_api_request_new_bucket_attached(app, mocker): assert 'error' in result[0].get_json() +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_without_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_new_bucket_without_version(app, mocker): + """``new_bucket_id`` without ``new_version_id`` must be rejected. + + ``ObjectVersion.get()`` deliberately falls back to the head version when + ``version_id`` is falsy, so the query alone would accept the request and + the missing version id would later be written into the record metadata. + """ + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mock_object_version = mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id=None) + assert result[1] == 403 + assert 'error' in result[0].get_json() + assert mock_object_version.call_count == 1 + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_with_empty_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_new_bucket_with_empty_version(app, mocker): + """An empty ``new_version_id`` string is rejected just like a missing one.""" + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_version_without_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_new_version_without_bucket(app, mocker): + """``new_version_id`` without ``new_bucket_id`` must be rejected too.""" + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + _mock_validation_dependencies(mocker) + mock_object_version = mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = None + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id=None, new_version_id='1') + assert result[1] == 403 + assert 'error' in result[0].get_json() + assert mock_object_version.call_count == 1 + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_pid_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test__validate_storage_api_request_pid_not_found(app, mocker): app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True diff --git a/modules/weko-records-ui/weko_records_ui/views.py b/modules/weko-records-ui/weko_records_ui/views.py index afddb1fb10..fe89c72e32 100644 --- a/modules/weko-records-ui/weko_records_ui/views.py +++ b/modules/weko-records-ui/weko_records_ui/views.py @@ -1477,9 +1477,16 @@ def dbsession_clean(exception): def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, - new_bucket_id=None, new_version_id=None): + new_bucket_id=None, new_version_id=None, + feature_flag_only=False): """Validate a request for the institutional storage APIs. + The record based checks (ownership, base recid, bucket and object) are + mandatory by default: a request without ``pid`` is rejected. Only the APIs + that do not operate on a single record (currently ``get_bucket_list``) may + opt out by passing ``feature_flag_only=True``, which stops right after the + feature flag check. + Returns None when the request is valid, otherwise a Flask response tuple that the caller can return as-is. """ @@ -1491,11 +1498,18 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, request.path, user_id)) return jsonify({'error': _('This feature is currently disabled.')}), 403 - if not pid: + if feature_flag_only: return None denied = jsonify( {'error': _('You do not have permission to perform this operation.')}), 403 + + if not pid: + current_app.logger.warning( + 'Storage API denied. reason=missing_pid, api={}, user_id={}'.format( + request.path, user_id)) + return denied + try: record = WekoRecord.get_record_by_pid(pid) if not check_created_id(record): @@ -1525,9 +1539,10 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, request.path, user_id, pid, bucket_id, file_name)) return denied - if new_bucket_id: - if ObjectVersion.get(bucket=new_bucket_id, key=file_name, - version_id=new_version_id) is None \ + if new_bucket_id or new_version_id: + if not (new_bucket_id and new_version_id) \ + or ObjectVersion.get(bucket=new_bucket_id, key=file_name, + version_id=new_version_id) is None \ or RecordsBuckets.query.filter_by( bucket_id=new_bucket_id).first() is not None: current_app.logger.warning( @@ -1553,7 +1568,7 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, @blueprint.route("/records/get_bucket_list", methods=['GET']) @login_required def get_bucket_list(): - error = _validate_storage_api_request() + error = _validate_storage_api_request(feature_flag_only=True) if error: return error From 1c656afcd3439b5525948b181a100adc96d9155b Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Tue, 1 Sep 2026 20:05:51 +0900 Subject: [PATCH 11/13] refactor(weko-records-ui): drop checks duplicated by the decorator - Drop the pid presence and check_created_id checks; the record_edit_permission_required decorator (2f6b61b2f) runs first - Keep the storage-specific checks the decorator does not cover - Update the tests to the decorator's responses and drop the ones covering the removed checks - Rewrite the docstring in Google style Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_views.py | 125 ++++++------------ .../weko-records-ui/weko_records_ui/views.py | 46 +++---- 2 files changed, 67 insertions(+), 104 deletions(-) diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index 4e8721d592..46b0b7b192 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -1713,6 +1713,18 @@ def _mock_validation_denied(mocker): return mocker.patch("weko_records_ui.views._validate_storage_api_request", return_value=(jsonify({'error': 'denied'}), 403)) +def _mock_edit_permission(mocker, permitted=True): + """Let ``record_edit_permission_required`` reach the view. + + The record based storage APIs are guarded by the decorator, which resolves + the record from ``pid`` and checks the edit permission on it. The unit + tests below do not create a record, so that lookup is mocked out. + """ + return mocker.patch( + "weko_records_ui.permissions.check_created_id_by_recid", + return_value=permitted) + + def _mock_storage_backends(mocker): """Mock every backend the storage APIs delegate to. @@ -1817,6 +1829,7 @@ def test_copy_bucket(app,records,users, client): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_success(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.copy_bucket_to_s3", return_value={}) @@ -1828,6 +1841,7 @@ def test_copy_bucket_success(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.copy_bucket_to_s3", side_effect=Exception) @@ -1931,6 +1945,7 @@ def test_get_file_place(app,records,users, client): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_success(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch( "weko_records_ui.views.get_file_place_info", @@ -1944,6 +1959,7 @@ def test_get_file_place_success(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.get_file_place_info", side_effect=Exception) @@ -2171,6 +2187,7 @@ def test_replace_file(app,records,users, client): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_s3_success(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) @@ -2182,6 +2199,7 @@ def test_replace_file_s3_success(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_s3_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", side_effect=Exception) @@ -2194,6 +2212,7 @@ def test_replace_file_s3_error(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_local_success(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) @@ -2205,6 +2224,7 @@ def test_replace_file_local_success(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_local_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", side_effect=Exception) @@ -2227,12 +2247,17 @@ def test_get_bucket_list_requires_login(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_requires_login(app, users, client, mocker): + """Anonymous requests are rejected before the view. + + The caller sends a JSON body, so the unauthorized handler answers 401 in + JSON instead of redirecting to the login page. + """ _setup_storage_api(app, client, users, do_login=False) backends = _mock_storage_backends(mocker) res = _call_copy_bucket(client) - assert res.status_code == 302 + assert res.status_code == 401 _assert_no_storage_access(backends) @@ -2273,6 +2298,7 @@ def test_get_bucket_list_denied_when_disabled(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_denied_when_disabled(app, users, client, mocker): _setup_storage_api(app, client, users, enabled=False) + _mock_edit_permission(mocker) backends = _mock_storage_backends(mocker) res = _call_copy_bucket(client) @@ -2285,6 +2311,7 @@ def test_copy_bucket_denied_when_disabled(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_denied_when_disabled(app, users, client, mocker): _setup_storage_api(app, client, users, enabled=False) + _mock_edit_permission(mocker) backends = _mock_storage_backends(mocker) res = _call_get_file_place(client) @@ -2297,6 +2324,7 @@ def test_get_file_place_denied_when_disabled(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_denied_when_disabled(app, users, client, mocker): _setup_storage_api(app, client, users, enabled=False) + _mock_edit_permission(mocker) backends = _mock_storage_backends(mocker) res = _call_replace_file_s3(client) @@ -2309,6 +2337,7 @@ def test_replace_file_denied_when_disabled(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_returns_validation_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_denied(mocker) backends = _mock_storage_backends(mocker) @@ -2322,6 +2351,7 @@ def test_copy_bucket_returns_validation_error(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_returns_validation_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_denied(mocker) backends = _mock_storage_backends(mocker) @@ -2335,6 +2365,7 @@ def test_get_file_place_returns_validation_error(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_returns_validation_error(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_denied(mocker) backends = _mock_storage_backends(mocker) @@ -2354,6 +2385,7 @@ def test_copy_bucket_passes_validation_params(app, users, client, mocker): every field so that a swapped or renamed key is detected. """ _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) mock_validate = _mock_validation_passed(mocker) backends = _mock_storage_backends(mocker) backends['copy_bucket_to_s3'].return_value = {} @@ -2374,6 +2406,7 @@ def test_get_file_place_passes_validation_params(app, users, client, mocker): form key is detected. """ _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) mock_validate = _mock_validation_passed(mocker) backends = _mock_storage_backends(mocker) backends['get_file_place_info'].return_value = ( @@ -2390,6 +2423,7 @@ def test_get_file_place_passes_validation_params(app, users, client, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_passes_new_bucket_params_s3 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_passes_new_bucket_params_s3(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) mock_validate = _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) @@ -2403,6 +2437,7 @@ def test_replace_file_passes_new_bucket_params_s3(app, users, client, mocker): def test_replace_file_passes_new_bucket_params_local(app, users, client, mocker): _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) mock_validate = _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) @@ -2420,7 +2455,8 @@ def test_copy_bucket_denied_without_pid(app, users, client, mocker): ``copy_bucket`` reads ``pid`` from the JSON body, and ``copy_bucket_to_s3`` locates the file from ``bucket_id`` / ``filename`` alone. Without this guard any logged in user could copy somebody else's - file into their own S3 bucket simply by leaving ``pid`` out. + file into their own S3 bucket simply by leaving ``pid`` out. The guard is + ``record_edit_permission_required``, which aborts with 400. """ _setup_storage_api(app, client, users) backends = _mock_storage_backends(mocker) @@ -2429,14 +2465,13 @@ def test_copy_bucket_denied_without_pid(app, users, client, mocker): res = _call_copy_bucket(client, payload) - assert res.status_code == 403 - assert 'error' in res.get_json() + assert res.status_code == 400 _assert_no_storage_access(backends) # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_denied_without_pid(app, users, client, mocker): - """A request without ``pid`` must be rejected instead of being trusted.""" + """A request without ``pid`` is rejected by ``record_edit_permission_required``.""" _setup_storage_api(app, client, users) backends = _mock_storage_backends(mocker) payload = dict(_GET_FILE_PLACE_PAYLOAD) @@ -2444,23 +2479,7 @@ def test_get_file_place_denied_without_pid(app, users, client, mocker): res = _call_get_file_place(client, payload) - assert res.status_code == 403 - assert 'error' in res.get_json() - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_denied_without_pid(app, users, client, mocker): - """A request without ``pid`` must be rejected instead of being trusted.""" - _setup_storage_api(app, client, users) - backends = _mock_storage_backends(mocker) - payload = dict(_REPLACE_FILE_S3_PAYLOAD) - del payload['pid'] - - res = _call_replace_file_s3(client, payload) - - assert res.status_code == 403 - assert 'error' in res.get_json() + assert res.status_code == 400 _assert_no_storage_access(backends) @@ -2490,6 +2509,7 @@ def test_replace_file_denied_without_new_version_id(app, users, client, mocker): ``version_id`` in the record metadata. """ _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_dependencies(mocker, deposit_bucket='1') mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") @@ -2509,6 +2529,7 @@ def test_replace_file_denied_without_new_version_id(app, users, client, mocker): def test_replace_file_denied_without_new_bucket_id(app, users, client, mocker): """``new_version_id`` without ``new_bucket_id`` must be rejected as well.""" _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) _mock_validation_dependencies(mocker, deposit_bucket='1') mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") @@ -2527,12 +2548,11 @@ def test_replace_file_denied_without_new_bucket_id(app, users, client, mocker): def _mock_validation_dependencies(mocker, deposit_bucket='aaa'): """Mock the dependencies of ``_validate_storage_api_request``. - The mocks let the ownership check and the base recid check pass, so that + The mocks let the record lookup and the base recid check pass, so that each test only has to override the branch it wants to exercise. """ pid_obj = mocker.MagicMock() mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': deposit_bucket}}) - mocker.patch("weko_records_ui.views.check_created_id", return_value=True) mocker.patch("weko_records_ui.views.PersistentIdentifier.get", return_value=pid_obj) mocker.patch("weko_records_ui.views.get_record_without_version", return_value=pid_obj) return pid_obj @@ -2561,67 +2581,10 @@ def test__validate_storage_api_request_feature_flag_only(app): assert result is None -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_no_pid(app, mocker): - """Omitting ``pid`` must not skip the record based checks. - - ``pid`` comes from the request body, so a caller could otherwise disable - the ownership, base recid and bucket checks simply by leaving it out. - """ - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - mock_get_record = mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid") - with app.test_request_context(): - result = _validate_storage_api_request() - assert result[1] == 403 - assert 'error' in result[0].get_json() - mock_get_record.assert_not_called() - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_empty_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_empty_pid(app, mocker): - """An empty ``pid`` string is rejected just like a missing one.""" - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - mock_get_record = mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid") - with app.test_request_context(): - result = _validate_storage_api_request(pid='', bucket_id='aaa', file_name='helloworld.pdf') - assert result[1] == 403 - assert 'error' in result[0].get_json() - mock_get_record.assert_not_called() - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_denied_message_is_shared -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_denied_message_is_shared(app, mocker): - """Every rejection reason must be indistinguishable in the response. - - The missing pid rejection reuses the existing permission message so that - the response never reveals which check failed. - """ - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) - mocker.patch("weko_records_ui.views.check_created_id", return_value=False) - with app.test_request_context(): - no_permission = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') - no_pid = _validate_storage_api_request() - assert no_pid[1] == no_permission[1] == 403 - assert no_pid[0].get_json() == no_permission[0].get_json() - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_no_permission -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_no_permission(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) - mocker.patch("weko_records_ui.views.check_created_id", return_value=False) - with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') - assert result[1] == 403 - assert 'error' in result[0].get_json() - - # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_not_base_recid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test__validate_storage_api_request_not_base_recid(app, mocker): app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) - mocker.patch("weko_records_ui.views.check_created_id", return_value=True) mocker.patch("weko_records_ui.views.PersistentIdentifier.get", return_value=mocker.MagicMock()) mocker.patch("weko_records_ui.views.get_record_without_version", return_value=mocker.MagicMock()) with app.test_request_context(): diff --git a/modules/weko-records-ui/weko_records_ui/views.py b/modules/weko-records-ui/weko_records_ui/views.py index 6845f17382..6cf38e3c5c 100644 --- a/modules/weko-records-ui/weko_records_ui/views.py +++ b/modules/weko-records-ui/weko_records_ui/views.py @@ -1486,14 +1486,27 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, feature_flag_only=False): """Validate a request for the institutional storage APIs. - The record based checks (ownership, base recid, bucket and object) are - mandatory by default: a request without ``pid`` is rejected. Only the APIs - that do not operate on a single record (currently ``get_bucket_list``) may - opt out by passing ``feature_flag_only=True``, which stops right after the - feature flag check. - - Returns None when the request is valid, otherwise a Flask response tuple - that the caller can return as-is. + Authentication, the presence of ``pid`` and the record ownership check are + handled by :func:`record_edit_permission_required`, so only the storage + specific checks are performed here. + + Args: + pid (str): Record id the request operates on. Must be the base recid. + bucket_id (str): Bucket id sent by the caller. Must be the deposit + bucket of the record. + file_name (str): Object key sent by the caller. Must exist in + ``bucket_id``. + new_bucket_id (str): Destination bucket id, set only when the file is + moved to a new bucket. + new_version_id (str): Destination object version id, set only when the + file is moved to a new bucket. + feature_flag_only (bool): Stop right after the feature flag check. Set + only by ``get_bucket_list``, which does not operate on a single + record. + + Returns: + tuple: ``(response, status_code)`` to be returned as-is when the + request is rejected, or None when it is valid. """ user_id = current_user.get_id() if not current_app.config.get( @@ -1509,19 +1522,8 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, denied = jsonify( {'error': _('You do not have permission to perform this operation.')}), 403 - if not pid: - current_app.logger.warning( - 'Storage API denied. reason=missing_pid, api={}, user_id={}'.format( - request.path, user_id)) - return denied - try: record = WekoRecord.get_record_by_pid(pid) - if not check_created_id(record): - current_app.logger.warning( - 'Storage API denied. reason=no_permission, api={}, user_id={}, ' - 'pid={}'.format(request.path, user_id, pid)) - return denied pid_obj = PersistentIdentifier.get('recid', pid) if pid_obj != get_record_without_version(pid_obj): @@ -1595,8 +1597,7 @@ def copy_bucket(): checked = data.get('checked') bucket_name = data.get('bucket_name') - error = _validate_storage_api_request( - pid=pid, bucket_id=bucket_id, file_name=filename) + error = _validate_storage_api_request(pid=pid, bucket_id=bucket_id, file_name=filename) if error: return error @@ -1617,8 +1618,7 @@ def get_file_place(): bucket_id = request.form.get('bucket_id') file_name = request.form.get('file_name') - error = _validate_storage_api_request( - pid=pid, bucket_id=bucket_id, file_name=file_name) + error = _validate_storage_api_request(pid=pid, bucket_id=bucket_id, file_name=file_name) if error: return error From a5f7744e21161fa1f70daa9a43a6fbd1ccb4925e Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Tue, 1 Sep 2026 20:06:39 +0900 Subject: [PATCH 12/13] test(weko-records-ui): consolidate similar tests with parametrize Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_views.py | 541 +++++++------------- 1 file changed, 197 insertions(+), 344 deletions(-) diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index 46b0b7b192..f53f9e9064 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -1749,6 +1749,19 @@ def _assert_no_storage_access(backends): mock.assert_not_called() +@pytest.fixture +def storage_api(app, client, users, mocker): + """Common preconditions of the logged in storage API tests. + + ``_setup_storage_api`` + ``_mock_edit_permission`` are repeated by nearly + every storage API test, so they are bundled here. The tests that need a + different setup (``enabled=False`` / ``do_login=False``) keep calling + ``_setup_storage_api`` directly. + """ + _setup_storage_api(app, client, users) + _mock_edit_permission(mocker) + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_bucket_list(app, records, users, client): # ビュー関数を直接呼ぶとデコレータを通らないため client 経由にした @@ -1760,28 +1773,6 @@ def test_get_bucket_list(app, records, users, client): assert client.get(url).status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_bucket_list_success(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.get_s3_bucket_list", return_value=[]) - - res = _call_get_bucket_list(client) - - assert res.status_code == 200 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_bucket_list_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.get_s3_bucket_list", side_effect=Exception) - - res = _call_get_bucket_list(client) - - assert res.status_code == 400 - - # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_acl_guest -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_bucket_list_acl_guest(app, records, users, client): """Lists the caller's own S3 buckets, so it needs a caller. @@ -1826,30 +1817,6 @@ def test_copy_bucket(app,records,users, client): assert res.status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_success(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.copy_bucket_to_s3", return_value={}) - - res = _call_copy_bucket(client) - - assert res.status_code == 200 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.copy_bucket_to_s3", side_effect=Exception) - - res = _call_copy_bucket(client) - - assert res.status_code == 400 - - # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_acl_guest -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_copy_bucket_acl_guest(app, records, users, client): """Anonymous requests get 401 JSON rather than the login page. @@ -1942,33 +1909,6 @@ def test_get_file_place(app,records,users, client): assert res.status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_success(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch( - "weko_records_ui.views.get_file_place_info", - return_value=('file_place', 'uri', 'new_bucket_id', 'new_version_id')) - - res = _call_get_file_place(client) - - assert res.status_code == 200 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.get_file_place_info", - side_effect=Exception) - - res = _call_get_file_place(client) - - assert res.status_code == 400 - - # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_acl_guest -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_get_file_place_acl_guest(app, records, users, client): """Anonymous requests are sent to the login screen.""" @@ -2184,195 +2124,136 @@ def test_replace_file(app,records,users, client): assert res.status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_s3_success(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) - - res = _call_replace_file_s3(client) - - assert res.status_code == 200 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_s3_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_s3_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.replace_file_bucket", - side_effect=Exception) - - res = _call_replace_file_s3(client) - - assert res.status_code == 400 - +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api, backend, return_value", + [ + (_call_get_bucket_list, 'get_s3_bucket_list', []), + (_call_copy_bucket, 'copy_bucket_to_s3', {}), + (_call_get_file_place, 'get_file_place_info', + ('file_place', 'uri', 'new_bucket_id', 'new_version_id')), + (_call_replace_file_s3, 'replace_file_bucket', {}), + (_call_replace_file_local, 'replace_file_bucket', {}), # local (else) branch + ], + ids=["get_bucket_list", "copy_bucket", "get_file_place", + "replace_file_s3", "replace_file_local"], +) +def test_storage_api_success(client, mocker, storage_api, call_api, backend, + return_value): + """Every storage API answers 200 when its backend succeeds. -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_local_success(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) + ``backend`` is a key of the dict returned by ``_mock_storage_backends`` + rather than a patch target built by string concatenation, so that grepping + for ``views.get_s3_bucket_list`` & co. still finds this test. + """ _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) + backends = _mock_storage_backends(mocker) + backends[backend].return_value = return_value - res = _call_replace_file_local(client) + res = call_api(client) assert res.status_code == 200 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_local_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_local_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api, backend", + [ + (_call_get_bucket_list, 'get_s3_bucket_list'), + (_call_copy_bucket, 'copy_bucket_to_s3'), + (_call_get_file_place, 'get_file_place_info'), + (_call_replace_file_s3, 'replace_file_bucket'), + (_call_replace_file_local, 'replace_file_bucket'), # local (else) branch + ], + ids=["get_bucket_list", "copy_bucket", "get_file_place", + "replace_file_s3", "replace_file_local"], +) +def test_storage_api_error(client, mocker, storage_api, call_api, backend): + """A failing backend is turned into 400 by every storage API.""" _mock_validation_passed(mocker) - mocker.patch("weko_records_ui.views.replace_file_bucket", - side_effect=Exception) - - res = _call_replace_file_local(client) - - assert res.status_code == 400 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_bucket_list_requires_login(app, users, client, mocker): - _setup_storage_api(app, client, users, do_login=False) backends = _mock_storage_backends(mocker) + backends[backend].side_effect = Exception - res = _call_get_bucket_list(client) + res = call_api(client) - assert res.status_code == 302 - _assert_no_storage_access(backends) + assert res.status_code == 400 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_requires_login(app, users, client, mocker): +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api, status_code", + [ + (_call_get_bucket_list, 302), # redirected to the login page + (_call_copy_bucket, 401), # JSON body, so the unauthorized handler answers in JSON + (_call_get_file_place, 302), # redirected to the login page + (_call_replace_file_s3, 302), # redirected to the login page + ], + ids=["get_bucket_list", "copy_bucket", "get_file_place", "replace_file_s3"], +) +def test_storage_api_requires_login(app, users, client, mocker, call_api, + status_code): """Anonymous requests are rejected before the view. - The caller sends a JSON body, so the unauthorized handler answers 401 in - JSON instead of redirecting to the login page. + Only ``copy_bucket`` answers 401 instead of 302: its caller sends a JSON + body, so the unauthorized handler replies in JSON rather than redirecting + -- a redirect would reach the fetch() caller as the login page's HTML and + fail while parsing. """ _setup_storage_api(app, client, users, do_login=False) backends = _mock_storage_backends(mocker) - res = _call_copy_bucket(client) - - assert res.status_code == 401 - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_requires_login(app, users, client, mocker): - _setup_storage_api(app, client, users, do_login=False) - backends = _mock_storage_backends(mocker) - - res = _call_get_file_place(client) - - assert res.status_code == 302 - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_requires_login -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_requires_login(app, users, client, mocker): - _setup_storage_api(app, client, users, do_login=False) - backends = _mock_storage_backends(mocker) - - res = _call_replace_file_s3(client) - - assert res.status_code == 302 - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_bucket_list_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_bucket_list_denied_when_disabled(app, users, client, mocker): - _setup_storage_api(app, client, users, enabled=False) - backends = _mock_storage_backends(mocker) - - res = _call_get_bucket_list(client) - - assert res.status_code == 403 - assert 'error' in res.get_json() - _assert_no_storage_access(backends) + res = call_api(client) - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_denied_when_disabled(app, users, client, mocker): - _setup_storage_api(app, client, users, enabled=False) - _mock_edit_permission(mocker) - backends = _mock_storage_backends(mocker) - - res = _call_copy_bucket(client) - - assert res.status_code == 403 - assert 'error' in res.get_json() - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_denied_when_disabled(app, users, client, mocker): - _setup_storage_api(app, client, users, enabled=False) - _mock_edit_permission(mocker) - backends = _mock_storage_backends(mocker) - - res = _call_get_file_place(client) - - assert res.status_code == 403 - assert 'error' in res.get_json() + assert res.status_code == status_code _assert_no_storage_access(backends) -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_denied_when_disabled(app, users, client, mocker): +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_denied_when_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api", + [ + _call_get_bucket_list, + _call_copy_bucket, + _call_get_file_place, + _call_replace_file_s3, + ], + ids=["get_bucket_list", "copy_bucket", "get_file_place", "replace_file_s3"], +) +def test_storage_api_denied_when_disabled(app, users, client, mocker, call_api): + """The feature flag is checked before any storage access happens.""" _setup_storage_api(app, client, users, enabled=False) + # get_bucket_list is not guarded by record_edit_permission_required, so the + # permission mock is never reached there -- applying it unconditionally is + # harmless and keeps the parametrization uniform. _mock_edit_permission(mocker) backends = _mock_storage_backends(mocker) - res = _call_replace_file_s3(client) + res = call_api(client) assert res.status_code == 403 assert 'error' in res.get_json() _assert_no_storage_access(backends) -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_returns_validation_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_denied(mocker) - backends = _mock_storage_backends(mocker) - - res = _call_copy_bucket(client) - - assert res.status_code == 403 - backends['copy_bucket_to_s3'].assert_not_called() - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_returns_validation_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_denied(mocker) - backends = _mock_storage_backends(mocker) - - res = _call_get_file_place(client) - - assert res.status_code == 403 - backends['get_file_place_info'].assert_not_called() - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_returns_validation_error(app, users, client, mocker): - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_returns_validation_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api", + [ + _call_copy_bucket, + _call_get_file_place, + _call_replace_file_s3, + ], + ids=["copy_bucket", "get_file_place", "replace_file_s3"], +) +def test_storage_api_returns_validation_error(client, mocker, storage_api, + call_api): + """A validator rejection is returned as-is, before any storage access.""" _mock_validation_denied(mocker) backends = _mock_storage_backends(mocker) - res = _call_replace_file_s3(client) + res = call_api(client) assert res.status_code == 403 - backends['replace_file_bucket'].assert_not_called() _assert_no_storage_access(backends) @@ -2448,36 +2329,31 @@ def test_replace_file_passes_new_bucket_params_local(app, users, client, -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_copy_bucket_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_copy_bucket_denied_without_pid(app, users, client, mocker): +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_storage_api_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "call_api, base_payload", + [ + (_call_copy_bucket, _COPY_BUCKET_PAYLOAD), # pid comes from the JSON body + (_call_get_file_place, _GET_FILE_PLACE_PAYLOAD), # pid comes from the form + ], + ids=["copy_bucket", "get_file_place"], +) +def test_storage_api_denied_without_pid(app, users, client, mocker, call_api, + base_payload): """``pid`` is attacker controlled, so omitting it must not bypass the checks. - ``copy_bucket`` reads ``pid`` from the JSON body, and - ``copy_bucket_to_s3`` locates the file from ``bucket_id`` / ``filename`` - alone. Without this guard any logged in user could copy somebody else's - file into their own S3 bucket simply by leaving ``pid`` out. The guard is - ``record_edit_permission_required``, which aborts with 400. + ``copy_bucket_to_s3`` / ``get_file_place_info`` locate the file from + ``bucket_id`` / file name alone, so without this guard any logged in user + could reach somebody else's file simply by leaving ``pid`` out. The guard + is ``record_edit_permission_required``, which aborts with 400 -- an HTML + error page, so there is no JSON body to assert on. """ _setup_storage_api(app, client, users) backends = _mock_storage_backends(mocker) - payload = dict(_COPY_BUCKET_PAYLOAD) - del payload['pid'] - - res = _call_copy_bucket(client, payload) - - assert res.status_code == 400 - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_denied_without_pid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_file_place_denied_without_pid(app, users, client, mocker): - """A request without ``pid`` is rejected by ``record_edit_permission_required``.""" - _setup_storage_api(app, client, users) - backends = _mock_storage_backends(mocker) - payload = dict(_GET_FILE_PLACE_PAYLOAD) + payload = dict(base_payload) del payload['pid'] - res = _call_get_file_place(client, payload) + res = call_api(client, payload) assert res.status_code == 400 _assert_no_storage_access(backends) @@ -2500,43 +2376,23 @@ def test_get_bucket_list_allowed_without_pid(app, users, client, mocker): assert res.get_json() == [] -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_new_version_id -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_denied_without_new_version_id(app, users, client, mocker): - """``new_bucket_id`` without ``new_version_id`` must be rejected at the entrance. +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_with_partial_new_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize("missing_key", ['new_version_id', 'new_bucket_id']) +def test_replace_file_denied_with_partial_new_bucket(client, mocker, + storage_api, missing_key): + """A half specified replacement target must be rejected at the entrance. - Otherwise ``ObjectVersion.get()`` silently falls back to the head version, - the request passes validation and ``None`` ends up stored as the file's - ``version_id`` in the record metadata. + ``ObjectVersion.get()`` deliberately falls back to the head version when + ``version_id`` is falsy, so ``new_bucket_id`` without ``new_version_id`` + would otherwise pass validation and ``None`` would end up stored as the + file's ``version_id`` in the record metadata. The mirror case + (``new_version_id`` without ``new_bucket_id``) is rejected as well. """ - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) _mock_validation_dependencies(mocker, deposit_bucket='1') - mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None - backends = _mock_storage_backends(mocker) - payload = dict(_REPLACE_FILE_S3_PAYLOAD) - del payload['new_version_id'] - - res = _call_replace_file_s3(client, payload) - - assert res.status_code == 403 - assert 'error' in res.get_json() - _assert_no_storage_access(backends) - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_new_bucket_id -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_replace_file_denied_without_new_bucket_id(app, users, client, mocker): - """``new_version_id`` without ``new_bucket_id`` must be rejected as well.""" - _setup_storage_api(app, client, users) - _mock_edit_permission(mocker) - _mock_validation_dependencies(mocker, deposit_bucket='1') - mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None + _mock_object_lookups(mocker) backends = _mock_storage_backends(mocker) payload = dict(_REPLACE_FILE_S3_PAYLOAD) - del payload['new_bucket_id'] + del payload[missing_key] res = _call_replace_file_s3(client, payload) @@ -2558,6 +2414,33 @@ def _mock_validation_dependencies(mocker, deposit_bucket='aaa'): return pid_obj +_UNSET = object() + + +def _mock_object_lookups(mocker, object_version=_UNSET, records_bucket=None): + """Mock the object / bucket lookups of ``_validate_storage_api_request``. + + ``ObjectVersion.get`` resolves the file (and, for a replacement, the new + file), and ``RecordsBuckets`` tells whether the new bucket is already + attached to a record. The defaults describe a valid request: the file is + found and the new bucket is still free. + """ + if object_version is _UNSET: + object_version = mocker.MagicMock() + mock_object_version = mocker.patch( + "weko_records_ui.views.ObjectVersion.get", return_value=object_version) + mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") + mock_records_buckets.query.filter_by.return_value.first.return_value = \ + records_bucket + return mock_object_version, mock_records_buckets + + +@pytest.fixture +def storage_api_enabled(app): + """Turn on the feature flag that every validator test but one needs.""" + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test__validate_storage_api_request_disabled(app): app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = False @@ -2569,21 +2452,19 @@ def test__validate_storage_api_request_disabled(app): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_feature_flag_only -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_feature_flag_only(app): +def test__validate_storage_api_request_feature_flag_only(app, storage_api_enabled): """``feature_flag_only=True`` stops right after the feature flag check. This is the only way to skip the record based checks, and it is used by ``get_bucket_list``, which does not operate on a single record. """ - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True with app.test_request_context(): result = _validate_storage_api_request(feature_flag_only=True) assert result is None # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_not_base_recid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_not_base_recid(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_not_base_recid(app, mocker, storage_api_enabled): mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) mocker.patch("weko_records_ui.views.PersistentIdentifier.get", return_value=mocker.MagicMock()) mocker.patch("weko_records_ui.views.get_record_without_version", return_value=mocker.MagicMock()) @@ -2594,8 +2475,7 @@ def test__validate_storage_api_request_not_base_recid(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_bucket_mismatch -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_bucket_mismatch(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_bucket_mismatch(app, mocker, storage_api_enabled): _mock_validation_dependencies(mocker) with app.test_request_context(): result = _validate_storage_api_request(pid='1', bucket_id='bbb', file_name='helloworld.pdf') @@ -2604,10 +2484,9 @@ def test__validate_storage_api_request_bucket_mismatch(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_object_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_object_not_found(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_object_not_found(app, mocker, storage_api_enabled): _mock_validation_dependencies(mocker) - mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=None) + _mock_object_lookups(mocker, object_version=None) with app.test_request_context(): result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') assert result[1] == 403 @@ -2615,8 +2494,12 @@ def test__validate_storage_api_request_object_not_found(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_invalid_new_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_invalid_new_version(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_invalid_new_version(app, mocker, storage_api_enabled): + """The second ``ObjectVersion.get`` -- for the replacement -- finds nothing. + + The two calls need different results, so this one keeps its own + ``side_effect`` instead of using ``_mock_object_lookups``. + """ _mock_validation_dependencies(mocker) mocker.patch("weko_records_ui.views.ObjectVersion.get", side_effect=[mocker.MagicMock(), None]) @@ -2627,72 +2510,46 @@ def test__validate_storage_api_request_invalid_new_version(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_attached -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_new_bucket_attached(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_new_bucket_attached(app, mocker, storage_api_enabled): _mock_validation_dependencies(mocker) - mocker.patch("weko_records_ui.views.ObjectVersion.get", - return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = \ - mocker.MagicMock() + _mock_object_lookups(mocker, records_bucket=mocker.MagicMock()) with app.test_request_context(): result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='1') assert result[1] == 403 assert 'error' in result[0].get_json() -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_without_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_new_bucket_without_version(app, mocker): - """``new_bucket_id`` without ``new_version_id`` must be rejected. +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_partial_new_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "new_bucket_id, new_version_id", + [ + ('bbb', None), # new_bucket_id without new_version_id + ('bbb', ''), # an empty new_version_id counts as missing too + (None, '1'), # new_version_id without new_bucket_id + ], +) +def test__validate_storage_api_request_partial_new_bucket( + app, mocker, storage_api_enabled, new_bucket_id, new_version_id): + """A half specified replacement target must be rejected. ``ObjectVersion.get()`` deliberately falls back to the head version when ``version_id`` is falsy, so the query alone would accept the request and - the missing version id would later be written into the record metadata. + the missing identifier would later be written into the record metadata. """ - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True _mock_validation_dependencies(mocker) - mock_object_version = mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None + mock_object_version, _ = _mock_object_lookups(mocker) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id=None) - assert result[1] == 403 - assert 'error' in result[0].get_json() - assert mock_object_version.call_count == 1 - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_with_empty_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_new_bucket_with_empty_version(app, mocker): - """An empty ``new_version_id`` string is rejected just like a missing one.""" - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - _mock_validation_dependencies(mocker) - mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None - with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='') - assert result[1] == 403 - assert 'error' in result[0].get_json() - - -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_version_without_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_new_version_without_bucket(app, mocker): - """``new_version_id`` without ``new_bucket_id`` must be rejected too.""" - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True - _mock_validation_dependencies(mocker) - mock_object_version = mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None - with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id=None, new_version_id='1') + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id=new_bucket_id, new_version_id=new_version_id) assert result[1] == 403 assert 'error' in result[0].get_json() + # ``not (new_bucket_id and new_version_id)`` short-circuits the or-chain in + # all three cases, so ObjectVersion.get is only called once (for the file + # itself) and never for the replacement. assert mock_object_version.call_count == 1 # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_pid_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_pid_not_found(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_pid_not_found(app, mocker, storage_api_enabled): mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=PIDDoesNotExistError('recid', '999')) with app.test_request_context(): result = _validate_storage_api_request(pid='999', bucket_id='aaa', file_name='helloworld.pdf') @@ -2702,8 +2559,7 @@ def test__validate_storage_api_request_pid_not_found(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_unexpected_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_unexpected_error(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_unexpected_error(app, mocker, storage_api_enabled): mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=Exception('boom')) with app.test_request_context(): result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') @@ -2712,12 +2568,9 @@ def test__validate_storage_api_request_unexpected_error(app, mocker): # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_success(app, mocker): - app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = True +def test__validate_storage_api_request_success(app, mocker, storage_api_enabled): _mock_validation_dependencies(mocker) - mocker.patch("weko_records_ui.views.ObjectVersion.get", return_value=mocker.MagicMock()) - mock_records_buckets = mocker.patch("weko_records_ui.views.RecordsBuckets") - mock_records_buckets.query.filter_by.return_value.first.return_value = None + _mock_object_lookups(mocker) with app.test_request_context(): result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf',new_bucket_id='bbb', new_version_id='1') assert result is None From 29e6faef281abac97f34abdcea13a0d29ef445ed Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Tue, 1 Sep 2026 22:05:32 +0900 Subject: [PATCH 13/13] fix(weko-records-ui): validate the s3 replacement target unconditionally - split the destination checks out of _validate_storage_api_request into _validate_new_file_target so the S3 branch always validates its target - extract _check_storage_feature_flag and drop the feature_flag_only flag, reducing _validate_storage_api_request from 6 arguments to 3 required ones Co-Authored-By: Claude Opus 5 (1M context) --- modules/weko-records-ui/tests/test_views.py | 225 +++++++++++++----- .../weko-records-ui/weko_records_ui/views.py | 136 +++++++---- 2 files changed, 255 insertions(+), 106 deletions(-) diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index f53f9e9064..cd302898bf 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -48,7 +48,9 @@ get_workflow_detail, preview_able, get_bucket_list, + _check_storage_feature_flag, _validate_storage_api_request, + _validate_new_file_target, ) from weko_records_ui.utils import create_download_url from .helpers import login @@ -1704,13 +1706,44 @@ def _call_replace_file_local(client): def _mock_validation_passed(mocker): - """Mock ``_validate_storage_api_request`` so that validation passes.""" - return mocker.patch("weko_records_ui.views._validate_storage_api_request",return_value=None) + """Mock every storage API validator so that validation passes. + + The three validators guard different entry points -- the feature flag + check alone for ``get_bucket_list``, the record checks for the record + based APIs and the destination checks for ``replace_file`` -- so they are + returned as a dict keyed by the part of the request they validate. + """ + return { + 'request': mocker.patch( + "weko_records_ui.views._validate_storage_api_request", + return_value=None), + 'new_target': mocker.patch( + "weko_records_ui.views._validate_new_file_target", + return_value=None), + 'feature_flag': mocker.patch( + "weko_records_ui.views._check_storage_feature_flag", + return_value=None), + } def _mock_validation_denied(mocker): - """Mock ``_validate_storage_api_request`` so that it denies the request.""" - return mocker.patch("weko_records_ui.views._validate_storage_api_request", return_value=(jsonify({'error': 'denied'}), 403)) + """Mock every storage API validator so that it denies the request. + + ``get_bucket_list`` only calls ``_check_storage_feature_flag``, so that + one has to be patched as well for the rejection to reach every API. + """ + denied = (jsonify({'error': 'denied'}), 403) + return { + 'request': mocker.patch( + "weko_records_ui.views._validate_storage_api_request", + return_value=denied), + 'new_target': mocker.patch( + "weko_records_ui.views._validate_new_file_target", + return_value=denied), + 'feature_flag': mocker.patch( + "weko_records_ui.views._check_storage_feature_flag", + return_value=denied), + } def _mock_edit_permission(mocker, permitted=True): @@ -2267,7 +2300,7 @@ def test_copy_bucket_passes_validation_params(app, users, client, mocker): """ _setup_storage_api(app, client, users) _mock_edit_permission(mocker) - mock_validate = _mock_validation_passed(mocker) + validators = _mock_validation_passed(mocker) backends = _mock_storage_backends(mocker) backends['copy_bucket_to_s3'].return_value = {} payload = dict(_COPY_BUCKET_PAYLOAD, pid='11', bucket_id='22', filename='target.pdf') @@ -2275,8 +2308,7 @@ def test_copy_bucket_passes_validation_params(app, users, client, mocker): res = _call_copy_bucket(client, payload) assert res.status_code == 200 - mock_validate.assert_called_once_with( - pid='11', bucket_id='22', file_name='target.pdf') + validators['request'].assert_called_once_with('11', '22', 'target.pdf') # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_file_place_passes_validation_params -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp @@ -2288,7 +2320,7 @@ def test_get_file_place_passes_validation_params(app, users, client, mocker): """ _setup_storage_api(app, client, users) _mock_edit_permission(mocker) - mock_validate = _mock_validation_passed(mocker) + validators = _mock_validation_passed(mocker) backends = _mock_storage_backends(mocker) backends['get_file_place_info'].return_value = ( 'file_place', 'uri', 'new_bucket_id', 'new_version_id') @@ -2297,35 +2329,39 @@ def test_get_file_place_passes_validation_params(app, users, client, mocker): res = _call_get_file_place(client, payload) assert res.status_code == 200 - mock_validate.assert_called_once_with( - pid='11', bucket_id='22', file_name='target.pdf') + validators['request'].assert_called_once_with('11', '22', 'target.pdf') # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_passes_new_bucket_params_s3 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_passes_new_bucket_params_s3(app, users, client, mocker): + """The S3 branch validates both the request and its destination.""" _setup_storage_api(app, client, users) _mock_edit_permission(mocker) - mock_validate = _mock_validation_passed(mocker) + validators = _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) res = _call_replace_file_s3(client) assert res.status_code == 200 - mock_validate.assert_called_once_with(pid='1', bucket_id='1', file_name='helloworld.pdf', new_bucket_id='1', new_version_id='1') + validators['request'].assert_called_once_with( + pid='1', bucket_id='1', file_name='helloworld.pdf') + validators['new_target'].assert_called_once_with('1', 'helloworld.pdf', '1', '1') # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_passes_new_bucket_params_local -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test_replace_file_passes_new_bucket_params_local(app, users, client, mocker): + """The local branch has no destination, so it never validates one.""" _setup_storage_api(app, client, users) _mock_edit_permission(mocker) - mock_validate = _mock_validation_passed(mocker) + validators = _mock_validation_passed(mocker) mocker.patch("weko_records_ui.views.replace_file_bucket", return_value={}) res = _call_replace_file_local(client) assert res.status_code == 200 - mock_validate.assert_called_once_with(pid='1', bucket_id='1', file_name='helloworld.pdf', new_bucket_id=None, new_version_id=None) + validators['request'].assert_called_once_with('1', '1', 'helloworld.pdf') + validators['new_target'].assert_not_called() @@ -2376,6 +2412,37 @@ def test_get_bucket_list_allowed_without_pid(app, users, client, mocker): assert res.get_json() == [] +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_without_new_target -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +@pytest.mark.parametrize( + "make_payload", + [ + lambda payload: {k: v for k, v in payload.items() + if k not in ('new_bucket_id', 'new_version_id')}, + lambda payload: dict(payload, new_bucket_id='', new_version_id=''), + ], + ids=["omitted", "empty"], +) +def test_replace_file_denied_without_new_target(client, mocker, storage_api, + make_payload): + """An S3 replacement without any destination must be rejected. + + Both identifiers missing used to leave them at ``None``, which made the + ``if new_bucket_id or new_version_id:`` guard of the shared validator + false and skipped the destination checks entirely, so unvalidated values + reached ``replace_file_bucket``. The destination is now validated + unconditionally on the S3 branch. + """ + _mock_validation_dependencies(mocker, deposit_bucket='1') + _mock_object_lookups(mocker) + backends = _mock_storage_backends(mocker) + + res = _call_replace_file_s3(client, make_payload(_REPLACE_FILE_S3_PAYLOAD)) + + assert res.status_code == 403 + assert 'error' in res.get_json() + _assert_no_storage_access(backends) + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_replace_file_denied_with_partial_new_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp @pytest.mark.parametrize("missing_key", ['new_version_id', 'new_bucket_id']) def test_replace_file_denied_with_partial_new_bucket(client, mocker, @@ -2451,18 +2518,27 @@ def test__validate_storage_api_request_disabled(app): assert 'error' in result[0].get_json() -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_feature_flag_only -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_feature_flag_only(app, storage_api_enabled): - """``feature_flag_only=True`` stops right after the feature flag check. +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__check_storage_feature_flag_enabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__check_storage_feature_flag_enabled(app, storage_api_enabled): + """The feature flag check alone is what ``get_bucket_list`` relies on. - This is the only way to skip the record based checks, and it is used by - ``get_bucket_list``, which does not operate on a single record. + It does not operate on a single record, so it skips the record based + checks by calling this validator instead of the full one. """ with app.test_request_context(): - result = _validate_storage_api_request(feature_flag_only=True) + result = _check_storage_feature_flag() assert result is None +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__check_storage_feature_flag_disabled -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__check_storage_feature_flag_disabled(app): + app.config['WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED'] = False + with app.test_request_context(): + result = _check_storage_feature_flag() + assert result[1] == 403 + assert 'error' in result[0].get_json() + + # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_not_base_recid -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp def test__validate_storage_api_request_not_base_recid(app, mocker, storage_api_enabled): mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", return_value={'_buckets': {'deposit': 'aaa'}}) @@ -2493,84 +2569,107 @@ def test__validate_storage_api_request_object_not_found(app, mocker, storage_api assert 'error' in result[0].get_json() -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_invalid_new_version -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_invalid_new_version(app, mocker, storage_api_enabled): - """The second ``ObjectVersion.get`` -- for the replacement -- finds nothing. - - The two calls need different results, so this one keeps its own - ``side_effect`` instead of using ``_mock_object_lookups``. - """ - _mock_validation_dependencies(mocker) - mocker.patch("weko_records_ui.views.ObjectVersion.get", - side_effect=[mocker.MagicMock(), None]) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_pid_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_pid_not_found(app, mocker, storage_api_enabled): + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=PIDDoesNotExistError('recid', '999')) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='1') + result = _validate_storage_api_request(pid='999', bucket_id='aaa', file_name='helloworld.pdf') assert result[1] == 403 + assert result[1] != 404 assert 'error' in result[0].get_json() -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_new_bucket_attached -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_new_bucket_attached(app, mocker, storage_api_enabled): +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_unexpected_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_unexpected_error(app, mocker, storage_api_enabled): + mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=Exception('boom')) + with app.test_request_context(): + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result[1] == 400 + assert result[0].get_json()['error'] == 'boom' + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_storage_api_request_success(app, mocker, storage_api_enabled): _mock_validation_dependencies(mocker) - _mock_object_lookups(mocker, records_bucket=mocker.MagicMock()) + _mock_object_lookups(mocker) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id='bbb', new_version_id='1') - assert result[1] == 403 - assert 'error' in result[0].get_json() + result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + assert result is None -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_partial_new_bucket -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_new_file_target_missing_new_target -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp @pytest.mark.parametrize( "new_bucket_id, new_version_id", [ - ('bbb', None), # new_bucket_id without new_version_id - ('bbb', ''), # an empty new_version_id counts as missing too - (None, '1'), # new_version_id without new_bucket_id + (None, None), # nothing at all -- used to skip the checks entirely + ('', ''), # both sent but empty + ('bbb', None), # new_bucket_id without new_version_id + ('bbb', ''), # an empty new_version_id counts as missing too + (None, '1'), # new_version_id without new_bucket_id ], + ids=["both_none", "both_empty", "no_version_id", "empty_version_id", + "no_bucket_id"], ) -def test__validate_storage_api_request_partial_new_bucket( - app, mocker, storage_api_enabled, new_bucket_id, new_version_id): - """A half specified replacement target must be rejected. +def test__validate_new_file_target_missing_new_target( + app, mocker, new_bucket_id, new_version_id): + """An incompletely specified replacement target must be rejected. ``ObjectVersion.get()`` deliberately falls back to the head version when ``version_id`` is falsy, so the query alone would accept the request and the missing identifier would later be written into the record metadata. """ - _mock_validation_dependencies(mocker) mock_object_version, _ = _mock_object_lookups(mocker) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf', new_bucket_id=new_bucket_id, new_version_id=new_version_id) + result = _validate_new_file_target( + pid='1', file_name='helloworld.pdf', + new_bucket_id=new_bucket_id, new_version_id=new_version_id) assert result[1] == 403 assert 'error' in result[0].get_json() - # ``not (new_bucket_id and new_version_id)`` short-circuits the or-chain in - # all three cases, so ObjectVersion.get is only called once (for the file - # itself) and never for the replacement. - assert mock_object_version.call_count == 1 + # The presence check comes first, so the destination lookup is never + # reached -- ObjectVersion.get must not be called at all here. + assert mock_object_version.call_count == 0 -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_pid_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_pid_not_found(app, mocker, storage_api_enabled): - mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=PIDDoesNotExistError('recid', '999')) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_new_file_target_new_object_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_new_file_target_new_object_not_found(app, mocker): + """The destination object does not exist in the destination bucket.""" + _mock_object_lookups(mocker, object_version=None) with app.test_request_context(): - result = _validate_storage_api_request(pid='999', bucket_id='aaa', file_name='helloworld.pdf') + result = _validate_new_file_target( + pid='1', file_name='helloworld.pdf', new_bucket_id='bbb', + new_version_id='1') assert result[1] == 403 - assert result[1] != 404 assert 'error' in result[0].get_json() -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_unexpected_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_unexpected_error(app, mocker, storage_api_enabled): - mocker.patch("weko_records_ui.views.WekoRecord.get_record_by_pid", side_effect=Exception('boom')) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_new_file_target_new_bucket_attached -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_new_file_target_new_bucket_attached(app, mocker): + """The destination bucket already belongs to a record.""" + _mock_object_lookups(mocker, records_bucket=mocker.MagicMock()) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf') + result = _validate_new_file_target( + pid='1', file_name='helloworld.pdf', new_bucket_id='bbb', + new_version_id='1') + assert result[1] == 403 + assert 'error' in result[0].get_json() + + +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_new_file_target_unexpected_error -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_new_file_target_unexpected_error(app, mocker): + mocker.patch("weko_records_ui.views.ObjectVersion.get", side_effect=Exception('boom')) + with app.test_request_context(): + result = _validate_new_file_target( + pid='1', file_name='helloworld.pdf', new_bucket_id='bbb', + new_version_id='1') assert result[1] == 400 assert result[0].get_json()['error'] == 'boom' -# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_storage_api_request_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test__validate_storage_api_request_success(app, mocker, storage_api_enabled): - _mock_validation_dependencies(mocker) +# .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test__validate_new_file_target_success -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp +def test__validate_new_file_target_success(app, mocker): _mock_object_lookups(mocker) with app.test_request_context(): - result = _validate_storage_api_request(pid='1', bucket_id='aaa', file_name='helloworld.pdf',new_bucket_id='bbb', new_version_id='1') + result = _validate_new_file_target( + pid='1', file_name='helloworld.pdf', new_bucket_id='bbb', + new_version_id='1') assert result is None diff --git a/modules/weko-records-ui/weko_records_ui/views.py b/modules/weko-records-ui/weko_records_ui/views.py index 6cf38e3c5c..6a7d0a08b7 100644 --- a/modules/weko-records-ui/weko_records_ui/views.py +++ b/modules/weko-records-ui/weko_records_ui/views.py @@ -1481,9 +1481,24 @@ def dbsession_clean(exception): db.session.remove() -def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, - new_bucket_id=None, new_version_id=None, - feature_flag_only=False): +def _check_storage_feature_flag(): + """Reject the request when the institutional storage APIs are disabled. + + Returns: + tuple: ``(response, status_code)`` to be returned as-is when the + feature is disabled, or None when it is enabled. + """ + if not current_app.config.get( + 'WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED', False): + current_app.logger.info( + 'Storage modification is disabled. api={}, user_id={}'.format( + request.path, current_user.get_id())) + return jsonify({'error': _('This feature is currently disabled.')}), 403 + + return None + + +def _validate_storage_api_request(pid, bucket_id, file_name): """Validate a request for the institutional storage APIs. Authentication, the presence of ``pid`` and the record ownership check are @@ -1496,29 +1511,16 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, bucket of the record. file_name (str): Object key sent by the caller. Must exist in ``bucket_id``. - new_bucket_id (str): Destination bucket id, set only when the file is - moved to a new bucket. - new_version_id (str): Destination object version id, set only when the - file is moved to a new bucket. - feature_flag_only (bool): Stop right after the feature flag check. Set - only by ``get_bucket_list``, which does not operate on a single - record. Returns: tuple: ``(response, status_code)`` to be returned as-is when the request is rejected, or None when it is valid. """ - user_id = current_user.get_id() - if not current_app.config.get( - 'WEKO_RECORDS_UI_USER_STORAGE_MODIFICATION_ENABLED', False): - current_app.logger.info( - 'Storage modification is disabled. api={}, user_id={}'.format( - request.path, user_id)) - return jsonify({'error': _('This feature is currently disabled.')}), 403 - - if feature_flag_only: - return None + error = _check_storage_feature_flag() + if error: + return error + user_id = current_user.get_id() denied = jsonify( {'error': _('You do not have permission to perform this operation.')}), 403 @@ -1545,18 +1547,6 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, 'pid={}, bucket_id={}, file_name={}'.format( request.path, user_id, pid, bucket_id, file_name)) return denied - - if new_bucket_id or new_version_id: - if not (new_bucket_id and new_version_id) \ - or ObjectVersion.get(bucket=new_bucket_id, key=file_name, - version_id=new_version_id) is None \ - or RecordsBuckets.query.filter_by( - bucket_id=new_bucket_id).first() is not None: - current_app.logger.warning( - 'Storage API denied. reason=invalid_new_bucket, api={}, ' - 'user_id={}, pid={}, new_bucket_id={}, new_version_id={}'.format( - request.path, user_id, pid, new_bucket_id, new_version_id)) - return denied except (PIDDoesNotExistError, NoResultFound): current_app.logger.warning( 'Storage API denied. reason=pid_not_found, api={}, user_id={}, ' @@ -1572,10 +1562,71 @@ def _validate_storage_api_request(pid=None, bucket_id=None, file_name=None, return None +def _validate_new_file_target(pid, file_name, new_bucket_id, new_version_id): + """Validate the destination a file is moved to by ``replace_file``. + + Only ``replace_file`` sends a destination, and it always sends one on its + S3 branch, so every check below runs unconditionally: a missing identifier + is a rejection, never a reason to skip the validation. + + Args: + pid (str): Record id the request operates on. Used for logging only. + file_name (str): Object key sent by the caller. Must exist in + ``new_bucket_id``. + new_bucket_id (str): Destination bucket id. Must not be attached to a + record yet. + new_version_id (str): Destination object version id. + + Returns: + tuple: ``(response, status_code)`` to be returned as-is when the + request is rejected, or None when it is valid. + """ + user_id = current_user.get_id() + denied = jsonify( + {'error': _('You do not have permission to perform this operation.')}), 403 + + try: + # ``ObjectVersion.get()`` deliberately falls back to the head version + # when ``version_id`` is falsy, so a half specified target would + # silently resolve to another object. Both identifiers must therefore + # be present before the lookup below is attempted -- keep this check + # first. + if not (new_bucket_id and new_version_id): + current_app.logger.warning( + 'Storage API denied. reason=missing_new_target, api={}, ' + 'user_id={}, pid={}, new_bucket_id={}, new_version_id={}'.format( + request.path, user_id, pid, new_bucket_id, new_version_id)) + return denied + + if ObjectVersion.get(bucket=new_bucket_id, key=file_name, + version_id=new_version_id) is None: + current_app.logger.warning( + 'Storage API denied. reason=new_object_not_found, api={}, ' + 'user_id={}, pid={}, new_bucket_id={}, new_version_id={}'.format( + request.path, user_id, pid, new_bucket_id, new_version_id)) + return denied + + if RecordsBuckets.query.filter_by( + bucket_id=new_bucket_id).first() is not None: + current_app.logger.warning( + 'Storage API denied. reason=new_bucket_attached, api={}, ' + 'user_id={}, pid={}, new_bucket_id={}, new_version_id={}'.format( + request.path, user_id, pid, new_bucket_id, new_version_id)) + return denied + except Exception as e: + current_app.logger.error( + 'Unexpected error while validating the new file target. ' + 'api={}, user_id={}, pid={}'.format(request.path, user_id, pid)) + current_app.logger.error(traceback.format_exc()) + return jsonify({'error': str(e)}), 400 + + return None + + @blueprint.route("/records/get_bucket_list", methods=['GET']) @login_required def get_bucket_list(): - error = _validate_storage_api_request(feature_flag_only=True) + error = _check_storage_feature_flag() if error: return error @@ -1597,7 +1648,7 @@ def copy_bucket(): checked = data.get('checked') bucket_name = data.get('bucket_name') - error = _validate_storage_api_request(pid=pid, bucket_id=bucket_id, file_name=filename) + error = _validate_storage_api_request(pid, bucket_id, filename) if error: return error @@ -1618,7 +1669,7 @@ def get_file_place(): bucket_id = request.form.get('bucket_id') file_name = request.form.get('file_name') - error = _validate_storage_api_request(pid=pid, bucket_id=bucket_id, file_name=file_name) + error = _validate_storage_api_request(pid, bucket_id, file_name) if error: return error @@ -1643,18 +1694,17 @@ def replace_file(): pid = request.form.get('pid') bucket_id = request.form.get('bucket_id') file_name = request.form.get('file_name') - new_bucket_id = request.form.get('new_bucket_id') \ - if return_file_place == 'S3' else None - new_version_id = request.form.get('new_version_id') \ - if return_file_place == 'S3' else None - - error = _validate_storage_api_request( - pid=pid, bucket_id=bucket_id, file_name=file_name, - new_bucket_id=new_bucket_id, new_version_id=new_version_id) + + error = _validate_storage_api_request(pid, bucket_id, file_name) if error: return error if (return_file_place == 'S3'): + new_bucket_id = request.form.get('new_bucket_id') + new_version_id = request.form.get('new_version_id') + error = _validate_new_file_target(pid, file_name, new_bucket_id, new_version_id) + if error: + return error file_size = int(request.form.get('file_size')) file_checksum = request.form.get('file_checksum')